diff --git a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/StartException.java b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/StartException.java index fb344ff..3a7bd37 100644 --- a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/StartException.java +++ b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/StartException.java @@ -4,4 +4,8 @@ public class StartException extends Exception { public StartException(String message) { super(message); } + + public StartException(String message, Throwable cause) { + super(message, cause); + } } diff --git a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Plugin.java b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Plugin.java index a2dd6b6..ef2224b 100644 --- a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Plugin.java +++ b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Plugin.java @@ -32,6 +32,7 @@ import io.roastedroot.proxywasm.WasmException; import io.roastedroot.proxywasm.WasmResult; import java.net.URI; +import java.net.URISyntaxException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,6 +41,9 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.function.Function; +/** + * Plugin is an instance of a Proxy-Wasm plugin. + */ public final class Plugin { private final ReentrantLock lock = new ReentrantLock(); @@ -81,10 +85,6 @@ public String name() { return name; } - public static Plugin.Builder builder() { - return new Plugin.Builder(); - } - public void lock() { lock.lock(); } @@ -135,13 +135,26 @@ public void close() { } } - public static class Builder implements Cloneable { + /** + * Creates a new Plugin builder. + * + * @return a new Plugin builder + */ + public static Plugin.Builder builder(WasmModule module) { + return new Plugin.Builder(module); + } + + /** + * Builder for creating a Plugin instance. + */ + public static final class Builder { - private ProxyWasm.Builder proxyWasmBuilder = ProxyWasm.builder().withStart(false); + private final WasmModule module; + private final ProxyWasm.Builder proxyWasmBuilder = ProxyWasm.builder().withStart(false); private boolean shared = true; private String name; private HashMap foreignFunctions; - private HashMap upstreams; + private HashMap upstreams; private boolean strictUpstreams; private int minTickPeriodMilliseconds; private LogHandler logger; @@ -151,105 +164,230 @@ public static class Builder implements Cloneable { private SharedQueueHandler sharedQueueHandler; private SharedDataHandler sharedDataHandler; + /** + * Set the WASM module of the plugin. The module contains the plugin instructions. + * + * @param module the WASM module of the plugin + * @return this builder + */ + private Builder(WasmModule module) { + this.module = module; + } + + /** + * Set the name of the plugin. + * + * @param name the name of the plugin + * @return this builder + */ public Plugin.Builder withName(String name) { this.name = name; return this; } + /** + * Set the foreign functions of that can be called from the plugin. + * + * @param functions the foreign functions of the plugin + * @return this builder + */ public Builder withForeignFunctions(Map functions) { this.foreignFunctions = new HashMap<>(functions); return this; } - public Builder withUpstreams(Map upstreams) { + /** + * Set the upstream server URL + * + * @param upstreams the upstream URI mappings. When a http or grpc call is made + * from the plugin, the upstream name is used to lookup the URL. + * @return this builder + */ + public Builder withUpstreams(Map upstreams) { this.upstreams = new HashMap<>(upstreams); return this; } + /** + * Set the strict upstreams mode of the plugin. If strict upstreams is enabled, + * then the plugin will throw an error if an upstream is not found. If disabled, + * then the upstream name is used as the URL. + * + * @param strictUpstreams the strict upstreams of the plugin + * @return this builder + */ public Builder withStrictUpstreams(boolean strictUpstreams) { this.strictUpstreams = strictUpstreams; return this; } + /** + * Set the minimum tick period of the plugin. A pluign that requests + * a very small tick period will be ticked very frequently. Use this + * to protect the host from being overwhelmed by the plugin. + * + * @param minTickPeriodMilliseconds the minimum tick period of the plugin + * @return this builder + */ public Builder withMinTickPeriodMilliseconds(int minTickPeriodMilliseconds) { this.minTickPeriodMilliseconds = minTickPeriodMilliseconds; return this; } + /** + * Set the logger of the plugin. + * + * @param logger the logger of the plugin + * @return this builder + */ public Builder withLogger(LogHandler logger) { this.logger = logger; return this; } + /** + * Set the metrics handler of the plugin. If the metrics handler is not set, + * then calls by the guest to define/use metrics will result in UNIMPLEMENTED errors + * reported to the guest. + * + * @param metricsHandler the metrics handler of the plugin + * @return this builder + */ public Builder withMetricsHandler(MetricsHandler metricsHandler) { this.metricsHandler = metricsHandler; return this; } + /** + * Set the shared queue handler of the plugin. If the sahred queue handler is not set, + * then calls by the guest to define/use shared queues will result in UNIMPLEMENTED errors + * reported to the guest. + * + * @param sharedQueueHandler the shared queue handler of the plugin + * @return this builder + */ public Builder withSharedQueueHandler(SharedQueueHandler sharedQueueHandler) { this.sharedQueueHandler = sharedQueueHandler; return this; } + /** + * Set the shared data handler of the plugin. If the shared data handler is not set, + * then calls by the guest to define/use shared data will result in UNIMPLEMENTED errors + * reported to the guest. + * + * @param sharedDataHandler the shared data handler of the plugin + * @return this builder + */ public Builder withSharedDataHandler(SharedDataHandler sharedDataHandler) { this.sharedDataHandler = sharedDataHandler; return this; } + /** + * Set whether the plugin is shared between host requests. If the plugin is shared, + * then the plugin will be created once and reused for each host request. If the plugin + * is not shared, then a new plugin MAY be use for each concurrent host request. + * + * @param shared whether the plugin is shared + * @return this builder + */ public Builder withShared(boolean shared) { this.shared = shared; return this; } + /** + * Set the VM config of the plugin. + * + * @param vmConfig the VM config of the plugin + * @return this builder + */ public Builder withVmConfig(byte[] vmConfig) { this.vmConfig = vmConfig; return this; } + /** + * Set the VM config of the plugin. + * + * @param vmConfig the VM config of the plugin + * @return this builder + */ public Builder withVmConfig(String vmConfig) { this.vmConfig = bytes(vmConfig); return this; } + /** + * Set the plugin config of the plugin. + * + * @param pluginConfig the plugin config of the plugin + * @return this builder + */ public Builder withPluginConfig(byte[] pluginConfig) { this.pluginConfig = pluginConfig; return this; } + /** + * Set the plugin config of the plugin. + * + * @param pluginConfig the plugin config of the plugin + * @return this builder + */ public Builder withPluginConfig(String pluginConfig) { this.pluginConfig = bytes(pluginConfig); return this; } + /** + * Set the import memory of the plugin. + * + * @param memory the import memory of the plugin + * @return this builder + */ public Builder withImportMemory(ImportMemory memory) { - proxyWasmBuilder = proxyWasmBuilder.withImportMemory(memory); + proxyWasmBuilder.withImportMemory(memory); return this; } + /** + * Set the machine factory of the plugin. The machine factory is used to control + * how instructions are executed. By default instructions are executed in a + * by an interpreter. To increase performance, you can use compile the + * was instructions to bytecode at runtime or at build time. For more information + * see https://chicory.dev/docs/experimental/aot + * + * @param machineFactory the machine factory of the plugin + * @return this builder + */ public Builder withMachineFactory(Function machineFactory) { proxyWasmBuilder.withMachineFactory(machineFactory); return this; } + /** + * Set the WASI options of the plugin. A default WASI enviroment will be provided + * to the pluign. You can use this method to customize the WASI environment, + * for example to provide it access to some file system resources. + * + * @param options the WASI options of the plugin + * @return this builder + */ public Builder withWasiOptions(WasiOptions options) { proxyWasmBuilder.withWasiOptions(options); return this; } - public Plugin build(WasmModule module) throws StartException { - return build(proxyWasmBuilder.build(module)); - } - - public Plugin build(Instance.Builder instanceBuilder) throws StartException { - return build(proxyWasmBuilder.build(instanceBuilder)); - } - - public Plugin build(Instance instance) throws StartException { - return build(proxyWasmBuilder.build(instance)); - } - - public Plugin build(ProxyWasm proxyWasm) throws StartException { - return new Plugin(this, proxyWasm); + /** + * Build the plugin. + * + * @return the plugin + * @throws StartException if the plugin fails to start + */ + public Plugin build() throws StartException { + return new Plugin(this, proxyWasmBuilder.build(module)); } } @@ -260,7 +398,7 @@ public Plugin build(ProxyWasm proxyWasm) throws StartException { private final AtomicInteger lastCallId = new AtomicInteger(0); private final HashMap httpCalls = new HashMap<>(); private final HashMap grpcCalls = new HashMap<>(); - private final HashMap upstreams; + private final HashMap upstreams; boolean strictUpstreams; int minTickPeriodMilliseconds; private int tickPeriodMilliseconds; @@ -406,19 +544,16 @@ public int httpCall( } headers.put("Host", authority); - var connectHostPort = upstreams.get(upstreamName); - if (connectHostPort == null && strictUpstreams) { + var connectUri = upstreams.get(upstreamName); + if (connectUri == null && strictUpstreams) { throw new WasmException(WasmResult.BAD_ARGUMENT); } - if (connectHostPort == null) { - connectHostPort = authority; - } - - URI connectUri = null; - try { - connectUri = URI.create(scheme + "://" + connectHostPort); - } catch (IllegalArgumentException e) { - throw new WasmException(WasmResult.BAD_ARGUMENT); + if (connectUri == null) { + try { + connectUri = new URI(upstreamName); + } catch (URISyntaxException e) { + throw new WasmException(WasmResult.BAD_ARGUMENT); + } } var connectHost = connectUri.getHost(); @@ -507,19 +642,17 @@ public int grpcCall( int timeoutMilliseconds) throws WasmException { - var connectHostPort = upstreams.get(upstreamName); - if (connectHostPort == null && strictUpstreams) { + var connectUri = upstreams.get(upstreamName); + if (connectUri == null && strictUpstreams) { throw new WasmException(WasmResult.BAD_ARGUMENT); } - if (connectHostPort == null) { - connectHostPort = upstreamName; - } - URI connectUri = null; - try { - connectUri = URI.create(connectHostPort); - } catch (IllegalArgumentException e) { - throw new WasmException(WasmResult.BAD_ARGUMENT); + if (connectUri == null) { + try { + connectUri = new URI(upstreamName); + } catch (URISyntaxException e) { + throw new WasmException(WasmResult.BAD_ARGUMENT); + } } if (!("http".equals(connectUri.getScheme()) diff --git a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/PluginFactory.java b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/PluginFactory.java index e5b1a35..80ac084 100644 --- a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/PluginFactory.java +++ b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/PluginFactory.java @@ -1,7 +1,5 @@ package io.roastedroot.proxywasm.plugin; -import io.roastedroot.proxywasm.StartException; - public interface PluginFactory { - Plugin create() throws StartException; + Plugin create() throws Exception; } diff --git a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Pool.java b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Pool.java index 9d76da5..acb4a27 100644 --- a/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Pool.java +++ b/proxy-wasm-java-host/src/main/java/io/roastedroot/proxywasm/plugin/Pool.java @@ -69,7 +69,12 @@ public String name() { @Override public Plugin borrow() throws StartException { - Plugin plugin = factory.create(); + Plugin plugin = null; + try { + plugin = factory.create(); + } catch (Throwable e) { + throw new StartException("Plugin create failed.", e); + } plugin.setServerAdaptor(serverAdaptor); plugin.wasm.start(); return plugin; diff --git a/proxy-wasm-java-host/src/test/go-examples/unit_tester/ffi.go b/proxy-wasm-java-host/src/test/go-examples/unit_tester/ffi.go index b50c4e6..e045f35 100644 --- a/proxy-wasm-java-host/src/test/go-examples/unit_tester/ffi.go +++ b/proxy-wasm-java-host/src/test/go-examples/unit_tester/ffi.go @@ -17,6 +17,7 @@ package main import ( "github.com/proxy-wasm/proxy-wasm-go-sdk/proxywasm" "github.com/proxy-wasm/proxy-wasm-go-sdk/proxywasm/types" + "github.com/tidwall/gjson" "strings" ) @@ -25,7 +26,6 @@ type ffiTests struct { types.DefaultHttpContext contextID uint32 pluginContext *pluginContext - path string } func ( @@ -36,48 +36,33 @@ p *pluginContext) ffiTests(contextID uint32) types.HttpContext { } } -func (ctx *ffiTests) OnHttpRequestHeaders(int, bool) types.Action { - pathBytes, err := proxywasm.GetProperty([]string{"request", "path"}) - if err != nil { - proxywasm.LogCriticalf("failed to get :path : %v", err) - } else { - ctx.path = string(pathBytes) - } - return types.ActionContinue -} - -func (ctx *ffiTests) OnHttpRequestBody(bodySize int, endOfStream bool) types.Action { - if strings.HasPrefix(ctx.path, "/ffiTests/") { +func (ctx *ffiTests) OnHttpResponseBody(bodySize int, endOfStream bool) types.Action { - // we need the full request body to call the FFI function - if !endOfStream { - // Wait until we see the entire body to replace. - return types.ActionPause - } + // we need the full response body to call the FFI function + if !endOfStream { + // Wait until we see the entire body to replace. + return types.ActionPause + } - funcName := strings.TrimPrefix(ctx.path, "/ffiTests/") - proxywasm.LogInfof("calling ffi: %s", funcName) + funcName := strings.TrimSpace(gjson.Get(ctx.pluginContext.config, "function").Str) + proxywasm.LogInfof("calling ffi: %s", funcName) - body, err := proxywasm.GetHttpRequestBody(0, bodySize) - if err != nil { - proxywasm.LogErrorf("failed to get request body: %v", err) - return types.ActionContinue - } + body, err := proxywasm.GetHttpResponseBody(0, bodySize) + if err != nil { + proxywasm.LogErrorf("failed to get response body: %v", err) + return types.ActionContinue + } - result, err := proxywasm.CallForeignFunction(funcName, body) - if err != nil { - proxywasm.LogErrorf("failed to call FFI: %v", err) - return types.ActionContinue - } + result, err := proxywasm.CallForeignFunction(funcName, body) + if err != nil { + proxywasm.LogErrorf("failed to call FFI: %v", err) + return types.ActionContinue + } - if err := proxywasm.SendHttpResponse(200, [][2]string{}, result, -1); err != nil { - proxywasm.LogErrorf("failed to send FFI response: %v", err) - return types.ActionContinue - } - + if err := proxywasm.ReplaceHttpResponseBody([]byte(result)); err != nil { + proxywasm.LogErrorf("failed to replace the response: %v", err) return types.ActionContinue } - proxywasm.LogInfo("noop") return types.ActionContinue } diff --git a/proxy-wasm-java-host/src/test/go-examples/unit_tester/main.wasm b/proxy-wasm-java-host/src/test/go-examples/unit_tester/main.wasm index fcb00e6..1b9743e 100644 Binary files a/proxy-wasm-java-host/src/test/go-examples/unit_tester/main.wasm and b/proxy-wasm-java-host/src/test/go-examples/unit_tester/main.wasm differ diff --git a/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/App.java b/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/App.java index b30c61e..f92b746 100644 --- a/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/App.java +++ b/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/App.java @@ -7,6 +7,7 @@ import io.roastedroot.proxywasm.StartException; import io.roastedroot.proxywasm.plugin.Plugin; import io.roastedroot.proxywasm.plugin.PluginFactory; +import java.net.URI; import java.nio.file.Path; import java.util.Map; @@ -21,44 +22,45 @@ public static WasmModule parseTestModule(String file) { public static PluginFactory headerTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("headerTests") .withLogger(new MockLogger("headerTests")) .withPluginConfig(gson.toJson(Map.of("type", "headerTests"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } public static PluginFactory headerTestsNotShared() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("headerTestsNotShared") .withShared(false) .withLogger(new MockLogger("headerTestsNotShared")) .withPluginConfig(gson.toJson(Map.of("type", "headerTests"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } public static PluginFactory tickTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("tickTests") .withLogger(new MockLogger("tickTests")) .withPluginConfig(gson.toJson(Map.of("type", "tickTests"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } public static PluginFactory ffiTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("ffiTests") .withLogger(new MockLogger("ffiTests")) - .withPluginConfig(gson.toJson(Map.of("type", "ffiTests"))) + .withPluginConfig( + gson.toJson(Map.of("type", "ffiTests", "function", "reverse"))) .withForeignFunctions(Map.of("reverse", App::reverse)) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } public static byte[] reverse(byte[] data) { @@ -71,7 +73,7 @@ public static byte[] reverse(byte[] data) { public static PluginFactory httpCallTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("httpCallTests") .withLogger(new MockLogger("httpCallTests")) .withPluginConfig( @@ -80,8 +82,8 @@ public static PluginFactory httpCallTests() throws StartException { "type", "httpCallTests", "upstream", "web_service", "path", "/ok"))) - .withUpstreams(Map.of("web_service", "localhost:8081")) + .withUpstreams(Map.of("web_service", new URI("http://localhost:8081"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } } diff --git a/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java b/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java index d947078..dce79e7 100644 --- a/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java +++ b/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java @@ -38,14 +38,14 @@ public Response ok() { @Path("/headerTests") @GET @WasmPlugin("headerTests") - public String uhttpHeaders(@HeaderParam("x-request-counter") String counter) { + public String httpHeaders(@HeaderParam("x-request-counter") String counter) { return String.format("counter: %s", counter); } @Path("/headerTestsNotShared") @GET @WasmPlugin("headerTestsNotShared") - public String unotSharedHttpHeaders(@HeaderParam("x-request-counter") String counter) { + public String notSharedHttpHeaders(@HeaderParam("x-request-counter") String counter) { return String.format("counter: %s", counter); } @@ -69,4 +69,11 @@ public String ffiTests(String body) { public String httpCallTests() { return "hello world"; } + + @Path("/httpCallTestsAndFFI") + @GET + @WasmPlugin({"ffiTests", "httpCallTests"}) + public String httpCallTestsAndFFI() { + return "hello world"; + } } diff --git a/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/tests/HttpCallTest.java b/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/tests/HttpCallTest.java index fb41721..620672c 100644 --- a/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/tests/HttpCallTest.java +++ b/proxy-wasm-jaxrs-jersey/src/test/java/io/roastedroot/proxywasm/jaxrs/example/tests/HttpCallTest.java @@ -18,4 +18,15 @@ public void test() throws InterruptedException, StartException { .body(equalTo("ok")) .header("echo-test", "ok"); } + + @Test + public void httpCallTestsAndFFI() throws InterruptedException { + given().header("test", "ok") + .when() + .get("/httpCallTestsAndFFI") + .then() + .statusCode(200) + .body(equalTo("ko")) + .header("echo-test", "ok"); + } } diff --git a/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/AbstractWasmPluginFeature.java b/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/AbstractWasmPluginFeature.java index 092d935..5e80e12 100644 --- a/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/AbstractWasmPluginFeature.java +++ b/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/AbstractWasmPluginFeature.java @@ -8,8 +8,10 @@ import jakarta.ws.rs.container.DynamicFeature; import jakarta.ws.rs.container.ResourceInfo; import jakarta.ws.rs.core.FeatureContext; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.stream.Collectors; public abstract class AbstractWasmPluginFeature implements DynamicFeature { @@ -23,7 +25,12 @@ public void init(Iterable factories, ServerAdaptor serverAdaptor) } for (var factory : factories) { - Plugin plugin = factory.create(); + Plugin plugin = null; + try { + plugin = factory.create(); + } catch (Throwable e) { + throw new StartException("Plugin create failed.", e); + } String name = plugin.name(); if (this.pluginPools.containsKey(name)) { throw new IllegalArgumentException("Duplicate wasm plugin name: " + name); @@ -63,10 +70,27 @@ public void configure(ResourceInfo resourceInfo, FeatureContext context) { resourceInfo.getResourceClass().getAnnotation(WasmPlugin.class); } if (pluignNameAnnotation != null) { - Pool pool = pluginPools.get(pluignNameAnnotation.value()); - if (pool != null) { - context.register(new WasmPluginFilter(pool)); - } + var pools = + Arrays.stream(pluignNameAnnotation.value()) + .map( + (name) -> { + Pool pool = pluginPools.get(name); + if (pool != null) { + return pool; + } else { + throw new IllegalArgumentException( + "Wasm plugin not found: " + + name + + " for resource: " + + resourceInfo + .getResourceClass() + .getName() + + "." + + resourceMethod.getName()); + } + }) + .collect(Collectors.toList()); + context.register(new WasmPluginFilter(pools)); } } } diff --git a/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPlugin.java b/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPlugin.java index 5cc576c..c4db714 100644 --- a/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPlugin.java +++ b/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPlugin.java @@ -10,5 +10,5 @@ @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) public @interface WasmPlugin { - String value(); + String[] value(); } diff --git a/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPluginFilter.java b/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPluginFilter.java index c5fa13e..e669de8 100644 --- a/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPluginFilter.java +++ b/proxy-wasm-jaxrs/src/main/java/io/roastedroot/proxywasm/jaxrs/WasmPluginFilter.java @@ -18,20 +18,27 @@ import jakarta.ws.rs.ext.WriterInterceptorContext; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.List; public class WasmPluginFilter implements ContainerRequestFilter, WriterInterceptor, ContainerResponseFilter { - private static final String FILTER_CONTEXT_PROPERTY_NAME = HttpContext.class.getName(); + private static final String FILTER_CONTEXT_PROPERTY_NAME = HttpContext.class.getName() + ":"; - private final Pool pluginPool; + private final List pluginPools; - public WasmPluginFilter(Pool pluginPool) { - this.pluginPool = pluginPool; + public WasmPluginFilter(List pluginPool) { + this.pluginPools = List.copyOf(pluginPool); } @Override public void filter(ContainerRequestContext requestContext) throws IOException { + for (var pluginPool : pluginPools) { + filter(requestContext, pluginPool); + } + } + private void filter(ContainerRequestContext requestContext, Pool pluginPool) + throws IOException { Plugin plugin; try { plugin = pluginPool.borrow(); @@ -46,7 +53,8 @@ public void filter(ContainerRequestContext requestContext) throws IOException { (JaxrsHttpRequestAdaptor) plugin.getServerAdaptor().httpRequestAdaptor(requestContext); var httpContext = plugin.createHttpContext(requestAdaptor); - requestContext.setProperty(FILTER_CONTEXT_PROPERTY_NAME, httpContext); + requestContext.setProperty( + FILTER_CONTEXT_PROPERTY_NAME + pluginPool.name(), httpContext); // the plugin may not be interested in the request headers. if (httpContext.context().hasOnRequestHeaders()) { @@ -106,7 +114,20 @@ private static Response interalServerError() { public void filter( ContainerRequestContext requestContext, ContainerResponseContext responseContext) throws IOException { - var httpContext = (HttpContext) requestContext.getProperty(FILTER_CONTEXT_PROPERTY_NAME); + for (var pluginPool : pluginPools) { + filter(requestContext, responseContext, pluginPool); + } + } + + private void filter( + ContainerRequestContext requestContext, + ContainerResponseContext responseContext, + Pool pluginPool) + throws IOException { + var httpContext = + (HttpContext) + requestContext.getProperty( + FILTER_CONTEXT_PROPERTY_NAME + pluginPool.name()); if (httpContext == null) { throw new WebApplicationException(interalServerError()); } @@ -169,18 +190,8 @@ public void filter( @Override public void aroundWriteTo(WriterInterceptorContext ctx) throws IOException, WebApplicationException { - var httpContext = (HttpContext) ctx.getProperty(FILTER_CONTEXT_PROPERTY_NAME); - if (httpContext == null) { - throw new WebApplicationException(interalServerError()); - } try { - httpContext.plugin().lock(); - - // the plugin may not be interested in the request body. - if (!httpContext.context().hasOnResponseBody()) { - ctx.proceed(); - } var original = ctx.getOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -188,32 +199,56 @@ public void aroundWriteTo(WriterInterceptorContext ctx) ctx.proceed(); byte[] bytes = baos.toByteArray(); - httpContext.setHttpResponseBody(bytes); - var action = httpContext.context().callOnResponseBody(true); - bytes = httpContext.getHttpResponseBody(); - if (action == Action.CONTINUE) { - // continue means plugin is done reading the body. - httpContext.setHttpResponseBody(null); - } else { - httpContext.maybePause(); - } - // does the plugin want to respond early? - var sendResponse = httpContext.consumeSentHttpResponse(); - if (sendResponse != null) { - throw new WebApplicationException(toResponse(sendResponse)); + for (var pluginPool : pluginPools) { + var httpContext = + (HttpContext) + ctx.getProperty(FILTER_CONTEXT_PROPERTY_NAME + pluginPool.name()); + if (httpContext == null) { + throw new WebApplicationException(interalServerError()); + } + + httpContext.plugin().lock(); + + // the plugin may not be interested in the request body. + if (!httpContext.context().hasOnResponseBody()) { + ctx.proceed(); + } + + httpContext.setHttpResponseBody(bytes); + var action = httpContext.context().callOnResponseBody(true); + bytes = httpContext.getHttpResponseBody(); + if (action == Action.CONTINUE) { + // continue means plugin is done reading the body. + httpContext.setHttpResponseBody(null); + } else { + httpContext.maybePause(); + } + + // does the plugin want to respond early? + var sendResponse = httpContext.consumeSentHttpResponse(); + if (sendResponse != null) { + throw new WebApplicationException(toResponse(sendResponse)); + } } // plugin may have modified the body original.write(bytes); } finally { - // allow other request to use the plugin. - httpContext.context().close(); - httpContext.plugin().unlock(); + for (var pluginPool : pluginPools) { + var httpContext = + (HttpContext) + ctx.getProperty(FILTER_CONTEXT_PROPERTY_NAME + pluginPool.name()); - // TODO: will aroundWriteTo always get called so that we can avoid leaking the plugin? - this.pluginPool.release(httpContext.plugin()); + // allow other request to use the plugin. + httpContext.context().close(); + httpContext.plugin().unlock(); + + // TODO: will aroundWriteTo always get called so that we can avoid leaking the + // plugin? + pluginPool.release(httpContext.plugin()); + } } } diff --git a/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/App.java b/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/App.java index cdfdbb2..23df933 100644 --- a/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/App.java +++ b/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/App.java @@ -21,10 +21,10 @@ public class App { @Produces public PluginFactory example() throws StartException { return () -> - Plugin.builder() + Plugin.builder(module) .withName("example") .withPluginConfig("{ \"type\": \"headerTests\" }") .withMachineFactory(AotMachine::new) - .build(module); + .build(); } } diff --git a/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java b/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java index 521b6cb..0c6a2b6 100644 --- a/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java +++ b/quarkus-proxy-wasm-example/src/main/java/io/roastedroot/proxywasm/jaxrs/example/Resources.java @@ -10,7 +10,7 @@ public class Resources { @Path("/test") @GET @WasmPlugin("example") // filter with example wasm plugin - public String ffiTests() { + public String example() { return "Hello World"; } } diff --git a/quarkus-proxy-wasm-example/src/test/java/io/roastedroot/proxywasm/jaxrs/example/FFITest.java b/quarkus-proxy-wasm-example/src/test/java/io/roastedroot/proxywasm/jaxrs/example/ResourcesTest.java similarity index 96% rename from quarkus-proxy-wasm-example/src/test/java/io/roastedroot/proxywasm/jaxrs/example/FFITest.java rename to quarkus-proxy-wasm-example/src/test/java/io/roastedroot/proxywasm/jaxrs/example/ResourcesTest.java index a45a80e..98bd1a1 100644 --- a/quarkus-proxy-wasm-example/src/test/java/io/roastedroot/proxywasm/jaxrs/example/FFITest.java +++ b/quarkus-proxy-wasm-example/src/test/java/io/roastedroot/proxywasm/jaxrs/example/ResourcesTest.java @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test; @QuarkusTest -public class FFITest { +public class ResourcesTest { @Test public void reverse() throws InterruptedException { diff --git a/quarkus-proxy-wasm/docs/modules/ROOT/pages/index.adoc b/quarkus-proxy-wasm/docs/modules/ROOT/pages/index.adoc index 996f4b7..df11aea 100644 --- a/quarkus-proxy-wasm/docs/modules/ROOT/pages/index.adoc +++ b/quarkus-proxy-wasm/docs/modules/ROOT/pages/index.adoc @@ -1,8 +1,27 @@ -= Quarkus Proxy Wasm - += Using the Proxy-Wasm Filter include::./includes/attributes.adoc[] +:categories: security +:keywords: wasm,jaxrs,filter +:summary: This guide explains how your Quarkus application can utilize proxy wasm plugins to filter requests to Jakarta REST endpoints. +:extension-name: Proxy-Wasm Filter +:topics: wasm + +This guide explains how your Quarkus application can utilize Proxy-Wasm plugins to filter requests to Jakarta REST (formerly known as JAX-RS) endpoints. + +Proxy-Wasm is a plugin system for network proxies. It lets you write plugins that can act as request filters in a portable, sandboxed, and language-agnostic way, thanks to WebAssembly. + +Docs and SDKs for plugin authors: + +* link:https://github.com/istio-ecosystem/wasm-extensions[Proxy-Wasm ABI specification] +* link:https://github.com/proxy-wasm/proxy-wasm-cpp-sdk[Proxy-Wasm C++ SDK] +* link:https://github.com/proxy-wasm/proxy-wasm-rust-sdk[Proxy-Wasm Rust SDK] +* link:https://github.com/proxy-wasm/proxy-wasm-go-sdk[Proxy-Wasm Go SDK] +* link:https://github.com/solo-io/proxy-runtime[Proxy-Wasm AssemblyScript SDK] -TIP: Describe what the extension does here. +Popular Proxy-Wasm plugins: + +* link:https://github.com/corazawaf/coraza-proxy-wasm[Coraza WAF] +* link:https://github.com/Kuadrant/wasm-shim/[Kuadrant] == Installation @@ -19,9 +38,137 @@ For instance, with Maven, add the following dependency to your POM file: ---- -[[extension-configuration-reference]] -== Extension Configuration Reference +=== Annotating the Jakarta REST resource + +We will walk you through the steps to create add a Proxy-Wasm filter to a Jakarta REST resource. +Let's assume you have an existing Jakarta REST resource that returns a simple string: + +[source,java] +---- +package org.example; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +@Path("/example") +public class Example { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "hello"; + } +} +---- + +To filter requests to that resource you would add a `@WasmPlugin` at either the class or method level depending on whether you want to filter all requests to the resource or just a specific method. + +The `@WasmPlugin` annotation should list the names of all the pluigns you want to apply to the resource or method. + +[source,java] +---- +package org.example; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import io.roastedroot.proxywasm.jaxrs.WasmPlugin; -TIP: Remove this section if you don't have Quarkus configuration properties in your extension. +@WasmPlugin("waf") +@Path("/example") +public class Example { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String hello() { + return "hello"; + } +} +---- + +The example above will apply the `waf` plugin instance to all requests to the `/example` resource. + +=== Configuring the Proxy-Wasm plugin + + +[source,java] +---- +package org.example; + +import com.dylibso.chicory.wasm.Parser; +import com.dylibso.chicory.wasm.WasmModule; +import io.roastedroot.proxywasm.StartException; +import io.roastedroot.proxywasm.plugin.Plugin; +import io.roastedroot.proxywasm.plugin.PluginFactory; +import io.roastedroot.proxywasm.plugin.SimpleMetricsHandler; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; + +@ApplicationScoped +public class App { + + private static WasmModule module = + Parser.parse(App.class.getResourceAsStream("coraza-proxy-wasm.wasm")); <1> + + // loads the plugin configuration as a classpath resource + static final String CONFIG; <4> + static { + try (InputStream is = App.class.getResourceAsStream("waf-config.json")) { + CONFIG = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + // creates a plugin factory that loads the wasm module and configuration + @Produces + public PluginFactory waf() throws StartException { + return () -> + Plugin.builder(module) + .withName("waf") <2> + .withPluginConfig(CONFIG) + .withMetricsHandler(new SimpleMetricsHandler()) <3> + .build(); + } +} + +---- + +<1> This will load the `src/main/resources/org/example/coraza-proxy-wasm.wasm` file. You can get that wasm module link:https://github.com/corazawaf/coraza-proxy-wasm/releases/tag/0.5.0[here]. +<2> The name of the plugin must match the name used in the `@WasmPlugin` annotation. +<3> The corazawf wasm module requires access to publish custom metrics. We give it `SimpleMetricsHandler` implementation which gives it access to do so, but those metrics are only kept in memory and don't get exposed anywhere. You can implement your own `MetricsHandler` to publish the metrics to a monitoring system of your choice. +<4> The configuration that will be passed to the plugin. Since we are loading from the classpath store the config inf `src/main/resources/org/example/waf-config.json`: + +[source,json] +---- +{ + "directives_map": { + "rs1": [ + "Include @demo-conf", + "Include @crs-setup-conf", + "SecDefaultAction \"phase:3,log,auditlog,pass\"", + "SecDefaultAction \"phase:4,log,auditlog,pass\"", + "SecDefaultAction \"phase:5,log,auditlog,pass\"", + "SecDebugLogLevel 9", + "Include @owasp_crs/*.conf", + "SecRule REQUEST_URI \"@streq /admin\" \"id:101,phase:1,t:lowercase,deny\" \nSecRule REQUEST_BODY \"@rx maliciouspayload\" \"id:102,phase:2,t:lowercase,deny\" \nSecRule RESPONSE_HEADERS::status \"@rx 406\" \"id:103,phase:3,t:lowercase,deny\" \nSecRule RESPONSE_BODY \"@contains responsebodycode\" \"id:104,phase:4,t:lowercase,deny\"" + ] + }, + "default_directives": "rs1", + "metric_labels": { + "owner": "coraza", + "identifier": "global" + }, + "per_authority_directives":{ + "bar.example.com":"rs1" + } +} +---- -include::includes/quarkus-proxy-wasm.adoc[leveloffset=+1, opts=optional] diff --git a/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/App.java b/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/App.java index 41d2db3..df44499 100644 --- a/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/App.java +++ b/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/App.java @@ -9,6 +9,7 @@ import io.roastedroot.proxywasm.plugin.PluginFactory; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; +import java.net.URI; import java.nio.file.Path; import java.util.Map; @@ -25,46 +26,47 @@ public static WasmModule parseTestModule(String file) { @Produces public PluginFactory headerTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("headerTests") .withLogger(new MockLogger("headerTests")) .withPluginConfig(gson.toJson(Map.of("type", "headerTests"))) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } @Produces public PluginFactory headerTestsNotShared() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("headerTestsNotShared") .withShared(false) .withLogger(new MockLogger("headerTestsNotShared")) .withPluginConfig(gson.toJson(Map.of("type", "headerTests"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } @Produces public PluginFactory tickTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("tickTests") .withLogger(new MockLogger("tickTests")) .withPluginConfig(gson.toJson(Map.of("type", "tickTests"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } @Produces public PluginFactory ffiTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("ffiTests") .withLogger(new MockLogger("ffiTests")) - .withPluginConfig(gson.toJson(Map.of("type", "ffiTests"))) + .withPluginConfig( + gson.toJson(Map.of("type", "ffiTests", "function", "reverse"))) .withForeignFunctions(Map.of("reverse", App::reverse)) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } public static byte[] reverse(byte[] data) { @@ -78,7 +80,7 @@ public static byte[] reverse(byte[] data) { @Produces public PluginFactory httpCallTests() throws StartException { return () -> - Plugin.builder() + Plugin.builder(parseTestModule("/go-examples/unit_tester/main.wasm")) .withName("httpCallTests") .withLogger(new MockLogger("httpCallTests")) .withPluginConfig( @@ -87,8 +89,8 @@ public PluginFactory httpCallTests() throws StartException { "type", "httpCallTests", "upstream", "web_service", "path", "/ok"))) - .withUpstreams(Map.of("web_service", "localhost:8081")) + .withUpstreams(Map.of("web_service", new URI("http://localhost:8081"))) .withMachineFactory(AotMachine::new) - .build(parseTestModule("/go-examples/unit_tester/main.wasm")); + .build(); } } diff --git a/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/Resources.java b/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/Resources.java index 138cd5f..7c3d3d8 100644 --- a/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/Resources.java +++ b/quarkus-proxy-wasm/integration-tests/src/main/java/io/quarkiverse/proxywasm/it/Resources.java @@ -69,4 +69,11 @@ public String ffiTests(String body) { public String httpCallTests() { return "hello world"; } + + @Path("/httpCallTestsAndFFI") + @GET + @WasmPlugin({"ffiTests", "httpCallTests"}) + public String httpCallTestsAndFFI() { + return "hello world"; + } } diff --git a/quarkus-proxy-wasm/integration-tests/src/test/java/io/quarkiverse/proxywasm/it/HttpCallTest.java b/quarkus-proxy-wasm/integration-tests/src/test/java/io/quarkiverse/proxywasm/it/HttpCallTest.java index 36d003f..a2c1ba3 100644 --- a/quarkus-proxy-wasm/integration-tests/src/test/java/io/quarkiverse/proxywasm/it/HttpCallTest.java +++ b/quarkus-proxy-wasm/integration-tests/src/test/java/io/quarkiverse/proxywasm/it/HttpCallTest.java @@ -22,4 +22,15 @@ public void test() throws InterruptedException, StartException { .body(equalTo("ok")) .header("echo-test", "ok"); } + + @Test + public void httpCallTestsAndFFI() throws InterruptedException { + given().header("test", "ok") + .when() + .get("/httpCallTestsAndFFI") + .then() + .statusCode(200) + .body(equalTo("ko")) + .header("echo-test", "ok"); + } } diff --git a/quarkus-x-corazawaf-example/src/main/java/io/roastedroot/proxywasm/corazawaf/example/App.java b/quarkus-x-corazawaf-example/src/main/java/io/roastedroot/proxywasm/corazawaf/example/App.java index f481fb3..f13625d 100644 --- a/quarkus-x-corazawaf-example/src/main/java/io/roastedroot/proxywasm/corazawaf/example/App.java +++ b/quarkus-x-corazawaf-example/src/main/java/io/roastedroot/proxywasm/corazawaf/example/App.java @@ -35,11 +35,11 @@ public class App { @Produces public PluginFactory waf() throws StartException { return () -> - Plugin.builder() + Plugin.builder(module) .withName("waf") .withLogger(DEBUG ? LogHandler.SYSTEM : null) .withPluginConfig(CONFIG) .withMetricsHandler(new SimpleMetricsHandler()) - .build(module); + .build(); } } diff --git a/quarkus-x-kuadrant-example/pom.xml b/quarkus-x-kuadrant-example/pom.xml index 869b29f..043fcf5 100644 --- a/quarkus-x-kuadrant-example/pom.xml +++ b/quarkus-x-kuadrant-example/pom.xml @@ -90,6 +90,7 @@ com.dylibso.chicory aot-maven-plugin-experimental + ${chicory.version} wasm-shim diff --git a/quarkus-x-kuadrant-example/src/main/java/io/roastedroot/proxywasm/kuadrant/example/App.java b/quarkus-x-kuadrant-example/src/main/java/io/roastedroot/proxywasm/kuadrant/example/App.java index e40bd58..26f0f48 100644 --- a/quarkus-x-kuadrant-example/src/main/java/io/roastedroot/proxywasm/kuadrant/example/App.java +++ b/quarkus-x-kuadrant-example/src/main/java/io/roastedroot/proxywasm/kuadrant/example/App.java @@ -10,6 +10,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.Map; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -35,13 +36,13 @@ public class App { @Produces public PluginFactory kuadrant() throws StartException { return () -> - Plugin.builder() + Plugin.builder(WasmShimModule.load()) .withName("kuadrant") .withMachineFactory(WasmShimModule::create) .withLogger(DEBUG ? LogHandler.SYSTEM : null) .withPluginConfig(CONFIG) - .withUpstreams(Map.of("limitador", limitadorUrl)) + .withUpstreams(Map.of("limitador", new URI(limitadorUrl))) .withMetricsHandler(new SimpleMetricsHandler()) - .build(WasmShimModule.load()); + .build(); } }