diff --git a/otoroshi/app/el/el.scala b/otoroshi/app/el/el.scala index 2e93bced2..848ac079b 100644 --- a/otoroshi/app/el/el.scala +++ b/otoroshi/app/el/el.scala @@ -12,7 +12,6 @@ import otoroshi.utils.http.RequestImplicits._ import kaleidoscope._ import otoroshi.next.extensions.HttpListenerNames import otoroshi.next.models.NgRoute -import otoroshi.next.plugins.Keys import otoroshi.utils.{ReplaceAllWith, TypedMap} import otoroshi.utils.syntax.implicits._ diff --git a/otoroshi/app/next/plugins/graphql.scala b/otoroshi/app/next/plugins/graphql.scala index 049e9a879..6e2236254 100644 --- a/otoroshi/app/next/plugins/graphql.scala +++ b/otoroshi/app/next/plugins/graphql.scala @@ -100,7 +100,7 @@ object GraphQLQueryConfig { "query" -> o.query, "timeout" -> o.timeout, "response_path" -> o.responsePath.map(JsString.apply).getOrElse(JsNull).asValue, - "response_filter" -> o.responsePath.map(JsString.apply).getOrElse(JsNull).asValue + "response_filter" -> o.responseFilter.map(JsString.apply).getOrElse(JsNull).asValue ) } } @@ -1164,7 +1164,7 @@ class GraphQLBackend extends NgBackendCall { builder = customBuilder, initialData = config.initialData.map(_.as[JsObject]).getOrElse(JsObject.empty), maxDepth = config.maxDepth, - variables = (jsonBody \ "variables").asOpt[JsValue].getOrElse(Json.obj()).as[JsObject] + variables = (jsonBody \ "variables").asOpt[JsObject].getOrElse(Json.obj()) ) case None => jsonResponse(400, Json.obj("error" -> "query field missing")).future } diff --git a/otoroshi/app/wasm/host.scala b/otoroshi/app/wasm/host.scala index 19ce292c3..12320026c 100644 --- a/otoroshi/app/wasm/host.scala +++ b/otoroshi/app/wasm/host.scala @@ -17,6 +17,7 @@ import otoroshi.utils.cache.types.UnboundedTrieMap import otoroshi.utils.json.JsonOperationsHelper import otoroshi.utils.syntax.implicits._ import otoroshi.utils.{ConcurrentMutableTypedMap, RegexPool, TypedMap} +import otoroshi.wasm.httpwasm.HttpWasmFunctions import play.api.Logger import play.api.libs.json._ @@ -1225,7 +1226,6 @@ object HostFunctions { Http.getFunctions(config, attrs) ++ State.getFunctions(config, pluginId) ++ DataStore.getFunctions(config, pluginId) - functions.collect { case func if func.authorized(config) => func.function }.toArray diff --git a/otoroshi/app/wasm/httpwasm/HttpWasmState.scala b/otoroshi/app/wasm/httpwasm/HttpWasmState.scala new file mode 100644 index 000000000..7b395d465 --- /dev/null +++ b/otoroshi/app/wasm/httpwasm/HttpWasmState.scala @@ -0,0 +1,394 @@ +package otoroshi.wasm.httpwasm + +import akka.stream.Materializer +import akka.stream.scaladsl.Source +import akka.util.ByteString +import com.sun.jna.Pointer +import org.extism.sdk.{ExtismCurrentPlugin, HostFunction} +import otoroshi.env.Env +import otoroshi.utils.syntax.implicits._ +import otoroshi.wasm.httpwasm.api.{BodyKind, LogLevel, _} +import play.api.Logger + +import scala.concurrent.{Await, ExecutionContext} +import scala.concurrent.duration.DurationInt + +class HttpWasmState(env: Env) { + + val logger = Logger("otoroshi-http-wasm") + + val u32Len = 4 + + def unimplementedFunction[A](name: String): A = { + logger.error(s"unimplemented state function: '${name}'") + throw new NotImplementedError(s"proxy state method '${name}' is not implemented") + } + + def enableFeatures(vmData: HttpWasmVmData, features: Int)(implicit mat: Materializer, ec: ExecutionContext): Int = { + vmData.features = vmData.features.withEnabled(features) + + if (vmData.features.isEnabled(Feature.FeatureBufferRequest)) { + vmData.request.body.runFold(ByteString.empty)(_ ++ _).map { b => + vmData.bufferedRequestBody = b.some + } + } + + if (vmData.features.isEnabled(Feature.FeatureBufferResponse)) { + vmData.response.body.runFold(ByteString.empty)(_ ++ _).map { b => + vmData.bufferedResponseBody = b.some + } + } + + vmData.features.f + } + + def getConfig(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, buf: Int, bufLimit: Int) = { + writeIfUnderLimit(plugin, buf, bufLimit, ByteString(vmData.config.stringify)) + } + + private def writeIfUnderLimit(plugin: ExtismCurrentPlugin, offset: Int, limit: Int, v: ByteString): Int = { + val vLen = v.length + if (vLen > limit || vLen == 0) { + return vLen + } + + val memory: Pointer = plugin.customMemoryGet() + memory.write(offset, v.toArray, 0, vLen) + + vLen + } + + private def writeNullTerminated(plugin: ExtismCurrentPlugin, buf: Int, bufLimit: Int, input: Seq[String]): BigInt = { + val count = input.length + if (count == 0) { + return 0 + } + + val encodedInput = input.map(i => ByteString(i)) + val byteCount = encodedInput.foldLeft(0) { case (acc, i) => acc + i.length } + + val countLen = (count << 32) | BigInt(byteCount) + + if (byteCount > bufLimit) { + return countLen + } + + var offset = 0 + + val memory: Pointer = plugin.customMemoryGet() + + encodedInput.foreach(s => { + val sLen = s.length + memory.write(buf + offset, s.toArray, 0, sLen) + offset += sLen + memory.setInt(buf + offset, 0) + offset += 1 + }) + + countLen + } + + private def writeStringIfUnderLimit( + plugin: ExtismCurrentPlugin, + offset: Int, + limit: Int, + v: String + ): Int = this.writeIfUnderLimit(plugin, offset, limit, ByteString(v)) + + def getHeaderNames( + plugin: ExtismCurrentPlugin, + vmData: HttpWasmVmData, + kind: HeaderKind, + buf: Int, + bufLimit: Int, + ): BigInt = { + val headers = vmData.headers(kind) + + val headerNames = headers.keys.toSeq + this.writeNullTerminated (plugin, buf, bufLimit, headerNames) + } + + def getHeaderValues( + plugin: ExtismCurrentPlugin, + vmData: HttpWasmVmData, + kind: HeaderKind, + name: Int, + nameLen: Int, + buf: Int, + bufLimit: Int + ): BigInt = { + + if (nameLen == 0) { + throw new RuntimeException("HTTP header name cannot be empty") + } + + val headers = vmData.headers(kind) + + val n = this.mustReadString(plugin, "name", name, nameLen).toLowerCase() + val value = headers.get(n) + val values: Seq[String] = value.map(value => value.split("; ").toSeq).getOrElse(Seq.empty) + + this.writeNullTerminated(plugin, buf, bufLimit, values) + } + + def getMethod(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, buf: Int, bufLimit: Int): Int = { + writeStringIfUnderLimit (plugin, buf, bufLimit, vmData.request.method) + } + + def getProtocolVersion(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, buf: Int, bufLimit: Int): Int = { + var httpVersion = vmData.request.version + httpVersion match { + case "1.0" => httpVersion = "HTTP/1.0" + case "1.1" => httpVersion = "HTTP/1.1" + case "2" => httpVersion = "HTTP/2.0" + case "2.0" => httpVersion = "HTTP/2.0" + case _ => httpVersion = httpVersion + } + + this.writeStringIfUnderLimit (plugin, buf, bufLimit, httpVersion) + } + + def getSourceAddr(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, buf: Int, bufLimit: Int): Int = { + this.writeStringIfUnderLimit (plugin, buf, bufLimit, vmData.remoteAddress.get) + } + + def getStatusCode(vmData: HttpWasmVmData): Int = vmData.requestStatusCode + + def getUri(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, buf: Int, bufLimit: Int): Int = { + val uri = vmData.request.relativeUri + this.writeStringIfUnderLimit (plugin, buf, bufLimit, if (uri.isEmpty) "/" else uri) + } + + def log(plugin: ExtismCurrentPlugin, level: LogLevel, buf: Int, bufLimit: Int) = { + val s = mustReadString(plugin, "log", buf, bufLimit) + + level match { + case LogLevel.LogLevelDebug => logger.debug(s) + case LogLevel.LogLevelInfo => logger.info(s) + case LogLevel.LogLevelWarn => logger.warn(s) + case LogLevel.LogLevelError => logger.error(s) + case _ => throw new Exception("invalid log level") + } + } + + def logEnabled(level: LogLevel): Int = { + if (level != LogLevel.LogLevelDebug) { + return 1 + } + 0 + } + + private def mustHeaderMutable(vmData: HttpWasmVmData, op: String, kind: HeaderKind) { + kind match { + case HeaderKind.HeaderKindRequest => mustBeforeNext(vmData, op, "request header") + case HeaderKind.HeaderKindResponse => mustBeforeNextOrFeature(vmData, Feature.FeatureBufferResponse, op, "response header") + case HeaderKind.HeaderKindRequestTrailers => mustBeforeNext(vmData, op, "request trailer") + case HeaderKind.HeaderKindResponseTrailers => mustBeforeNextOrFeature(vmData, Feature.FeatureBufferResponse, op, "response trailer") + } + } + + private def mustBeforeNext(vmData: HttpWasmVmData, op: String, kind: String) { + if (vmData.afterNext) { + throw new RuntimeException(s"can't $op $kind after next handler") + } + } + + private def mustBeforeNextOrFeature(vmData: HttpWasmVmData, feature: Feature, op: String, kind: String): Unit = { + if (!vmData.afterNext) { + // Assume this is serving a response from the guest. + } else if (vmData.features.isEnabled(feature)) { + // Assume the guest is overwriting the response from next. + } else { + throw new RuntimeException(s"can't $op $kind after next handler unless " + + s"${Feature.toString(feature)} is enabled") + } + } + + private def _readBody(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, buf: Int, bufLimit: Int, body: ByteString, kind: BodyKind): BigInt = { + // buf_limit 0 serves no purpose as implementations won't return EOF on it. + if (bufLimit == 0) { + throw new RuntimeException("buf_limit==0 reading body") + } + + val memory = plugin.customMemoryGet() + + val start = kind match { + case BodyKind.BodyKindRequest => vmData.requestBodyReadIndex + case BodyKind.BodyKindResponse => vmData.requestBodyReadIndex + case _ => throw new Exception("invalid body kind") + } + val end = Math.min (start + bufLimit, body.length) + val slice = body.slice(start, end) + + memory.write(buf, slice.toArray, 0, slice.length) + kind match { + case BodyKind.BodyKindRequest => + vmData.requestBodyReadIndex = end + case BodyKind.BodyKindResponse => + vmData.responseBodyReadIndex = end + } + + if (end == body.length) { + return (1 << 32) | BigInt (slice.length) + } + BigInt (slice.length) +} + + + def readBody(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, kind: BodyKind, buf: Int, bufLimit: Int): BigInt = { + val body = kind match { + case BodyKind.BodyKindRequest => + mustBeforeNextOrFeature(vmData, Feature.FeatureBufferRequest, "read", BodyKind.toString(BodyKind.BodyKindRequest)) + + if (vmData.bufferedRequestBody.isEmpty) { + vmData.bufferedRequestBody = Some(Await.result(vmData.request.body.runFold(ByteString.empty)(_ ++ _) + (env.otoroshiMaterializer), 10.seconds)) + } + + vmData.bufferedRequestBody.get + + case BodyKind.BodyKindResponse => + mustBeforeNextOrFeature(vmData, Feature.FeatureBufferResponse, "read", BodyKind.toString(BodyKind.BodyKindResponse)) + + + if (vmData.bufferedResponseBody.isEmpty) { + vmData.bufferedResponseBody = Some(Await.result(vmData.response.body.runFold(ByteString.empty)(_ ++ _) + (env.otoroshiMaterializer), 10.seconds)) + } + + vmData.bufferedResponseBody.get + } + + _readBody(plugin, vmData, buf, bufLimit, body, kind) + } + + def setMethod(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, method: Int, methodLen: Int) { + mustBeforeNext(vmData, "set", "method") + + if (methodLen == 0) { + throw new RuntimeException("HTTP method cannot be empty") + } + + val readMethod = this.mustReadString(plugin, "method", method, methodLen) + + vmData.setMethod(readMethod) + } + + def writeBody(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, kind: BodyKind, body: Int, bodyLen: Int) = { + var b: ByteString = ByteString.empty + + if (bodyLen == 0) { + b = ByteString.empty + } else { + b = this.mustRead(plugin, "body", body, bodyLen) + } + + vmData.setBody(Source.single(b), kind) + } + + def addHeader( + plugin: ExtismCurrentPlugin, + vmData: HttpWasmVmData, + kind: HeaderKind, + name: Int, + nameLen: Int, + value: Int, + valueLen: Int + ) = { + if (nameLen == 0) { + throw new RuntimeException("HTTP header name cannot be empty") + } + + mustHeaderMutable(vmData, "add", kind) + + val n = this.mustReadString (plugin, "name", name, nameLen) + val v = this.mustReadString (plugin, "value", value, valueLen) + + val headers = vmData.headers(kind) + val existing = headers.get(n) + val newValue = existing.map(existing => Seq(existing, v)).getOrElse(Seq(v)) + + vmData.setHeader(kind, n, newValue) + } + + def setHeader( + plugin: ExtismCurrentPlugin, + vmData: HttpWasmVmData, + kind: HeaderKind, + name: Int, + nameLen: Int, + value: Int, + valueLen: Int + ) = { + if (nameLen == 0) { + throw new RuntimeException("HTTP header name cannot be empty") + } + + mustHeaderMutable(vmData, "set", kind) + + val n = this.mustReadString (plugin, "name", name, nameLen) + val v = this.mustReadString (plugin, "value", value, valueLen) + + vmData.setHeader (kind, n, Seq(v)) + } + + def removeHeader( + plugin: ExtismCurrentPlugin, + vmData: HttpWasmVmData, + kind: HeaderKind, + name: Int, + nameLen: Int): Unit = { + if (nameLen == 0) { + throw new RuntimeException ("HTTP header name cannot be empty") + } + + mustHeaderMutable(vmData, "remove", kind) + + val n = this.mustReadString (plugin, "name", name, nameLen) + vmData.removeHeader (kind, n) + } + + def setStatusCode(vmData: HttpWasmVmData, statusCode: Int): Unit = { + vmData.setResponse(vmData.response.copy(status = statusCode)) + } + + def setUri(plugin: ExtismCurrentPlugin, vmData: HttpWasmVmData, uri: Int, uriLen: Int): Unit = { + + mustBeforeNext(vmData, "set", "uri") + + val u = if (uriLen > 0) { + this.mustReadString(plugin, "uri", uri, uriLen) + } else { + "" + } + + vmData.setUri(u) + } + + private def mustReadString( + plugin: ExtismCurrentPlugin, + fieldName: String, + offset: Int, + byteCount: Int, + ): String = { + if (byteCount == 0) { + return "" + } + + this.mustRead(plugin, fieldName, offset, byteCount).utf8String + } + + private def mustRead( + plugin: ExtismCurrentPlugin, + fieldName: String, + offset: Int, + byteCount: Int + ): ByteString = { + if (byteCount == 0) { + return ByteString.empty + } + + val memory: Pointer = plugin.customMemoryGet() + ByteString(memory.share(offset).getByteArray(0, byteCount)) + } +} diff --git a/otoroshi/app/wasm/httpwasm/functions.scala b/otoroshi/app/wasm/httpwasm/functions.scala new file mode 100644 index 000000000..0820ae4c0 --- /dev/null +++ b/otoroshi/app/wasm/httpwasm/functions.scala @@ -0,0 +1,424 @@ +package otoroshi.wasm.httpwasm + +import akka.stream.Materializer +import akka.stream.scaladsl.Source +import akka.util.ByteString +import io.otoroshi.wasm4s.scaladsl._ +import org.extism.sdk.{ExtismCurrentPlugin, HostFunction, HostUserData, LibExtism} +import otoroshi.env.Env +import otoroshi.next.plugins.api.{NgPluginHttpRequest, NgPluginHttpResponse} +import otoroshi.utils.syntax.implicits.BetterSyntax +import otoroshi.wasm.httpwasm.HttpWasmFunctions.parameters +import otoroshi.wasm.httpwasm.api.{BodyKind, Feature, Features, HeaderKind, LogLevel} +import play.api.libs.json.{JsObject, Json} + +import java.util.Optional +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.ExecutionContext + +case class HttpWasmVmData( + config: JsObject = Json.obj(), + properties: Map[String, Array[Byte]] = Map.empty, + var requestStatusCode: Int = 200, + var request: NgPluginHttpRequest, + var response: NgPluginHttpResponse, + var features: Features = Features(3 | Feature.FeatureBufferRequest.value | Feature.FeatureBufferResponse.value | Feature.FeatureTrailers.value), + var nextCalled: Boolean = false, + var requestBodyReadIndex: Int = 0, + var responseBodyReadIndex: Int = 0, + var bufferedRequestBody: Option[ByteString] = None, + var bufferedResponseBody: Option[ByteString] = None, + var afterNext: Boolean = false, + var remoteAddress: Option[String] = None + ) extends HostUserData + with WasmVmData { + def headers(kind: HeaderKind): Map[String, String] = { + kind match { + case HeaderKind.HeaderKindRequest => request.headers + case HeaderKind.HeaderKindResponse => response.headers + case HeaderKind.HeaderKindRequestTrailers => ??? // TODO + case HeaderKind.HeaderKindResponseTrailers => ??? // TODO + } + } + + def setRequest(newRequest: NgPluginHttpRequest) = { + request = newRequest + } + + def setResponse(newResponse: NgPluginHttpResponse) = { + response = newResponse + } + + def setMethod(method: String) = { + setRequest(request.copy(method = method)) + } + + def setUri(uri: String) = { + setRequest(request.copy(url = uri)) + } + + def setBody(body: Source[ByteString, _], bodyKind: BodyKind) = { + bodyKind match { + case BodyKind.BodyKindRequest => setRequest(request.copy(body = body)) + case BodyKind.BodyKindResponse => setResponse(response.copy(body = body)) + } + + } + + def setHeader(kind: HeaderKind, key: String, value: Seq[String]) = { + kind match { + case HeaderKind.HeaderKindRequest => setRequest(request.copy(headers = request.headers ++ Map(key -> value.head))) + case HeaderKind.HeaderKindResponse => setResponse(response.copy(headers = response.headers ++ Map(key -> value.head))) + case HeaderKind.HeaderKindRequestTrailers => ??? // TODO + case HeaderKind.HeaderKindResponseTrailers => ??? // TODO + } + } + + def removeHeader(kind: HeaderKind, key: String) = { + kind match { + case HeaderKind.HeaderKindRequest => setRequest(request.copy(headers = request.headers - key)) + case HeaderKind.HeaderKindResponse => setResponse(response.copy(headers = response.headers - key)) + case HeaderKind.HeaderKindRequestTrailers => ??? // TODO + case HeaderKind.HeaderKindResponseTrailers => ??? // TODO + } + } +} + +object HttpWasmVmData { + def withRequest(request: NgPluginHttpRequest) = HttpWasmVmData( + request = request, + response = NgPluginHttpResponse( + status = 200, + headers = Map.empty[String, String], + body = Source.empty + )) +} + +object AdministrativeFunctions { + def all(state: HttpWasmState, getCurrentVmData: () => HttpWasmVmData) + (implicit mat: Materializer, ec: ExecutionContext) = { + Seq( + new HostFunction[EnvUserData]( + "enable_features", + parameters(1), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => returns(0).v.i32 = state.enableFeatures(getCurrentVmData(), params(0).v.i32), + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "get_config", + parameters(2), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.getConfig(plugin, getCurrentVmData(), params(0).v.i32, params(0).v.i32), + Optional.empty[EnvUserData]() + ) + ) + .map(_.withNamespace("http_handler")) + } +} + +object LoggingFunctions { + def all(state: HttpWasmState, getCurrentVmData: () => HttpWasmVmData) = { + Seq( + new HostFunction[EnvUserData]( + "log", + parameters(3), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.log(plugin, LogLevel.fromValue(params(0).v.i32), params(1).v.i32, params(2).v.i32), + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "log_enabled", + parameters(1), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.logEnabled(LogLevel.fromValue(params(0).v.i32)), + Optional.empty[EnvUserData]() + ) + ) + .map(_.withNamespace("http_handler")) + } +} + +object HeaderFunctions { + def all(state: HttpWasmState, getCurrentVmData: () => HttpWasmVmData) = { + Seq( + new HostFunction[EnvUserData]( + "get_header_names", + parameters(3), + Array(LibExtism.ExtismValType.I64), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + returns(0).v.i64 = state.getHeaderNames(plugin, + getCurrentVmData(), + HeaderKind.fromValue(params(0).v.i32), + params(1).v.i32, + params(2).v.i32).longValue() + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "get_header_values", + parameters(5), + Array(LibExtism.ExtismValType.I64), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + returns(0).v.i64 = state.getHeaderValues(plugin, getCurrentVmData(), + HeaderKind.fromValue(params(0).v.i32), + params(1).v.i32, + params(2).v.i32, + params(3).v.i32, + params(4).v.i32).longValue() + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "set_header_value", + parameters(5), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + state.setHeader(plugin, getCurrentVmData(), HeaderKind.fromValue(params(0).v.i32), params(1).v.i32, params(2).v.i32, params(3).v.i32, params(4).v.i32) + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "add_header_value", + parameters(5), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + state.addHeader(plugin, getCurrentVmData(), HeaderKind.fromValue(params(0).v.i32), params(1).v.i32, params(2).v.i32, params(3).v.i32, params(4).v.i32) + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "remove_header", + parameters(3), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.removeHeader(plugin, getCurrentVmData(), HeaderKind.fromValue(params(0).v.i32), params(1).v.i32, params(2).v.i32), + Optional.empty[EnvUserData]() + ) + ) + .map(_.withNamespace("http_handler")) + } +} + +object BodyFunctions { + def all(state: HttpWasmState, getCurrentVmData: () => HttpWasmVmData) = { + Seq( + new HostFunction[EnvUserData]( + "read_body", + parameters(3), + Array(LibExtism.ExtismValType.I64), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => returns(0).v.i64 = state.readBody(plugin, getCurrentVmData(), BodyKind.fromValue(params(0).v.i32), params(1).v.i32, params(2).v.i32).longValue(), + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "write_body", + parameters(3), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.writeBody(plugin, getCurrentVmData(), BodyKind.fromValue(params(0).v.i32), params(1).v.i32, params(2).v.i32), + Optional.empty[EnvUserData]() + ) + ) + .map(_.withNamespace("http_handler")) + } +} + +object RequestFunctions { + def all(state: HttpWasmState, getCurrentVmData: () => HttpWasmVmData) = { + Seq( + new HostFunction[EnvUserData]( + "get_method", + parameters(2), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + returns(0).v.i32 = state.getMethod(plugin, getCurrentVmData(), params(0).v.i32, params(1).v.i32) + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "set_method", + parameters(2), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.setMethod(plugin, getCurrentVmData(), params(0).v.i32, params(1).v.i32), + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "get_uri", + parameters(2), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + returns(0).v.i32 = state.getUri(plugin, getCurrentVmData(), params(0).v.i32, params(1).v.i32) + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "set_uri", + parameters(2), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.setUri(plugin, getCurrentVmData(), params(0).v.i32, params(1).v.i32), + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "get_protocol_version", + parameters(2), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + returns(0).v.i32 = state.getProtocolVersion(plugin, getCurrentVmData(), params(0).v.i32, params(1).v.i32) + }, + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "get_source_addr", + parameters(2), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => { + returns(0).v.i32 = state.getSourceAddr(plugin, getCurrentVmData(), params(0).v.i32, params(1).v.i32) + }, + Optional.empty[EnvUserData]() + ) + ) + .map(_.withNamespace("http_handler")) + } +} + +object ResponseFunctions { + def all(state: HttpWasmState, getCurrentVmData: () => HttpWasmVmData) = { + Seq( + new HostFunction[EnvUserData]( + "get_status_code", + parameters(0), + parameters(1), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.getStatusCode(getCurrentVmData()), + Optional.empty[EnvUserData]() + ), + new HostFunction[EnvUserData]( + "set_status_code", + parameters(1), + parameters(0), + ( + plugin: ExtismCurrentPlugin, + params: Array[LibExtism.ExtismVal], + returns: Array[LibExtism.ExtismVal], + data: Optional[EnvUserData] + ) => state.setStatusCode(getCurrentVmData(), params(0).v.i32), + Optional.empty[EnvUserData]() + ) + ) + .map(_.withNamespace("http_handler")) + } +} + +object HttpWasmFunctions { + def parameters(n: Int): Array[LibExtism.ExtismValType] = { + (0 until n).map(_ => LibExtism.ExtismValType.I32).toArray + } + + def build( + state: HttpWasmState, + vmDataRef: AtomicReference[WasmVmData] + )(implicit ec: ExecutionContext, env: Env, mat: Materializer): Seq[HostFunction[EnvUserData]] = { + def getCurrentVmData(): HttpWasmVmData = { + Option(vmDataRef.get()) match { + case Some(data: HttpWasmVmData) => data + case _ => + new RuntimeException("missing vm data").printStackTrace() + throw new RuntimeException("missing vm data") + } + } + + AdministrativeFunctions.all(state, getCurrentVmData) ++ + LoggingFunctions.all(state, getCurrentVmData) ++ + HeaderFunctions.all(state, getCurrentVmData) ++ + BodyFunctions.all(state, getCurrentVmData) ++ + RequestFunctions.all(state, getCurrentVmData) ++ + ResponseFunctions.all(state, getCurrentVmData) + } +} \ No newline at end of file diff --git a/otoroshi/app/wasm/httpwasm/httpwasm.scala b/otoroshi/app/wasm/httpwasm/httpwasm.scala new file mode 100644 index 000000000..2f4268199 --- /dev/null +++ b/otoroshi/app/wasm/httpwasm/httpwasm.scala @@ -0,0 +1,236 @@ +package otoroshi.wasm.httpwasm + +import akka.stream.Materializer +import io.otoroshi.wasm4s.scaladsl._ +import org.extism.sdk.wasmotoroshi._ +import org.extism.sdk.{HostFunction, HostUserData} +import otoroshi.env.Env +import otoroshi.gateway.Errors +import otoroshi.next.plugins.api._ +import otoroshi.utils.TypedMap +import otoroshi.utils.syntax.implicits._ +import otoroshi.wasm._ +import play.api._ +import play.api.libs.json._ +import play.api.libs.typedmap.TypedKey +import play.api.mvc.Results.{BadRequest, Status} + +import java.util.concurrent.atomic._ +import scala.concurrent._ +import scala.util._ + + +object HttpWasmPluginKeys { + val HttpWasmVmKey = TypedKey[WasmVm]("otoroshi.next.plugins.HttpWasmVm") +} + +class HttpWasmPlugin(wasm: WasmConfig, key: String, env: Env) { + + private implicit val ev = env + private implicit val ec = env.otoroshiExecutionContext + private implicit val ma = env.otoroshiMaterializer + + private lazy val state = new HttpWasmState(env) + private lazy val pool: WasmVmPool = WasmVmPool.forConfigurationWithId(key, wasm)(env.wasmIntegration.context) + + def createFunctions(ref: AtomicReference[WasmVmData]): Seq[HostFunction[_ <: HostUserData]] = { + HttpWasmFunctions.build(state, ref) + } + + def start(attrs: TypedMap): Future[Unit] = { + pool.getPooledVm(WasmVmInitOptions( + importDefaultHostFunctions = false, + resetMemory = true, + addHostFunctions = createFunctions + )).flatMap { vm => + attrs.put(otoroshi.wasm.httpwasm.HttpWasmPluginKeys.HttpWasmVmKey -> vm) + vm.finitialize { + Future.successful(()) + } + } + } + +} + +class NgHttpWasm extends NgRequestTransformer { + + override def steps: Seq[NgStep] = Seq(NgStep.TransformRequest, NgStep.TransformResponse) + override def categories: Seq[NgPluginCategory] = Seq(NgPluginCategory.Wasm) + override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand + override def multiInstance: Boolean = true + override def core: Boolean = true + override def name: String = "Http WASM" + override def description: Option[String] = "Http WASM plugin".some + override def defaultConfigObject: Option[NgPluginConfig] = WasmConfig().some + + override def isTransformRequestAsync: Boolean = true + override def isTransformResponseAsync: Boolean = true + override def usesCallbacks: Boolean = true + override def transformsRequest: Boolean = true + override def transformsResponse: Boolean = true + override def transformsError: Boolean = false + + override def beforeRequest(ctx: NgBeforeRequestContext) + (implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Unit] = { + val config = WasmConfig.format.reads(ctx.config).getOrElse(WasmConfig()) + new HttpWasmPlugin(config, "http-wasm", env).start(ctx.attrs) + } + + private def handleResponse(vm: WasmVm, vmData: HttpWasmVmData, reqCtx: Int, isError: Int) + (implicit env: Env, ec: ExecutionContext) = { + vmData.afterNext = true + vm.call( + WasmFunctionParameters.NoResult("handle_response", new Parameters(2).pushInts(reqCtx, isError)), + vmData.some + ) + } + + private def execute(vm: WasmVm, ctx: NgTransformerRequestContext) + (implicit env: Env, ec: ExecutionContext): Future[Either[mvc.Result, NgPluginHttpRequest]] = { + val vmData = HttpWasmVmData + .withRequest(ctx.otoroshiRequest) + .some + + vmData.get.remoteAddress = ctx.request.remoteAddress.some + + vm.callWithParamsAndResult("handle_request", + new Parameters(0), + 1, + None, + vmData + ) + .flatMap { + case Left(error) => { + Errors.craftResponseResult( + error.toString(), + Status(401), + ctx.request, + None, + None, + attrs = TypedMap.empty + ).map(r => Left(r)) + } + case Right(res) => + if (res.results.getLength > 0) { + val ctxNext = res.results.getValue(0).v.i64 + + val data = vmData.get + if ((ctxNext & 0x1) != 0x1) { + Left(data.response.asResult).future + } else { + val reqCtx = ctxNext >> 32 + handleResponse(vm, data, reqCtx.toInt, 0) + + Right(ctx.otoroshiRequest.copy( + headers = data.request.headers, + url = data.request.url, + method = data.request.method, + body = data.request.body + )).future + } + } else { + Left(BadRequest(Json.obj("error" -> "missing handle request result"))).future + } + } + } + + override def transformRequest( + ctx: NgTransformerRequestContext + )(implicit env: Env, ec: ExecutionContext, mat: Materializer): + Future[Either[mvc.Result, NgPluginHttpRequest]] = { + ctx.attrs.get(otoroshi.wasm.httpwasm.HttpWasmPluginKeys.HttpWasmVmKey) match { + case None => Future.failed(new RuntimeException("no vm found in attrs")) + case Some(vm) => execute(vm, ctx) + } + } + + override def afterRequest( + ctx: NgAfterRequestContext + )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Unit] = { + ctx.attrs.get(otoroshi.wasm.httpwasm.HttpWasmPluginKeys.HttpWasmVmKey).foreach(_.release()) + ().vfuture + } + + // TODO - only useful for testing +// override def transformResponse( +// ctx: NgTransformerResponseContext +// )(implicit env: Env, ec: ExecutionContext, mat: Materializer): Future[Either[Result, NgPluginHttpResponse]] = { +// ctx.attrs.get(otoroshi.wasm.httpwasm.HttpWasmPluginKeys.HttpWasmVmKey) match { +// case None => +// println("no vm found in attrs") +// Future.failed(new RuntimeException("no vm found in attrs")) +// case Some(vm) => +// val vmData = HttpWasmVmData +// .withRequest(NgPluginHttpRequest( +// headers = ctx.otoroshiResponse.headers, +// url = ctx.request.uri, +// method = ctx.request.method, +// version = ctx.request.version, +// clientCertificateChain = () => None, +// cookies = Seq.empty, +// body = Source.empty, +// backend = None +// )) +// vmData.remoteAddress = ctx.request.remoteAddress.some +// vmData.response = vmData.response.copy( +// headers = ctx.otoroshiResponse.headers, +// status = ctx.otoroshiResponse.status, +// cookies = ctx.otoroshiResponse.cookies, +// body = ctx.otoroshiResponse.body +// ) +// +// vm.callWithParamsAndResult("handle_request", +// new Parameters(0), +// 1, +// None, +// vmData.some +// ) +// .flatMap { +// case Left(error) => { +// Errors.craftResponseResult( +// error.toString(), +// Status(401), +// ctx.request, +// None, +// None, +// attrs = TypedMap.empty +// ).map(r => Left(r)) +// } +// case Right(res) => +// if(res.results.getLength() > 0){ +// val ctxNext = res.results.getValue(0).v.i64 +// +// val data = vmData +// if ((ctxNext & 0x1) != 0x1) { +// Left(data.response.asResult).future +// } else { +// data.nextCalled = true +// +// val reqCtx = ctxNext >> 32 +// handleResponse(vm, data, reqCtx.toInt, 0) +// +// implicit val mat = env.otoroshiMaterializer +// +// if (data.request.hasBody) { +// Right(ctx.otoroshiResponse.copy( +// headers = data.response.headers, +// status = data.response.status, +// cookies = data.response.cookies, +// body = data.response.body, +// )).future +// } else { +// Right(ctx.otoroshiResponse.copy( +// headers = data.response.headers, +// status = data.response.status, +// cookies = data.response.cookies +// )).future +// } +// } +// } else { +// println("missing handle request result") +// Left(BadRequest(Json.obj("error" -> "missing handle request result"))).future +// } +// } +// } +// } +} \ No newline at end of file diff --git a/otoroshi/app/wasm/httpwasm/utils.scala b/otoroshi/app/wasm/httpwasm/utils.scala new file mode 100644 index 000000000..87dff432b --- /dev/null +++ b/otoroshi/app/wasm/httpwasm/utils.scala @@ -0,0 +1,135 @@ +package otoroshi.wasm.httpwasm.api + +sealed trait HeaderKind { + def value: Int +} + +object HeaderKind { + case object HeaderKindRequest extends HeaderKind { + def value: Int = 0 + } + case object HeaderKindResponse extends HeaderKind { + def value: Int = 1 + } + case object HeaderKindRequestTrailers extends HeaderKind { + def value: Int = 2 + } + case object HeaderKindResponseTrailers extends HeaderKind { + def value: Int = 3 + } + + def fromValue(value: Int): HeaderKind = { + value match { + case 0 => HeaderKindRequest + case 1 => HeaderKindResponse + case 2 => HeaderKindRequestTrailers + case 3 => HeaderKindResponseTrailers + case _ => throw new Exception("invalid header kind") + } + } +} + +sealed trait BodyKind { + def value: Int +} + +object BodyKind { + case object BodyKindRequest extends BodyKind { + def value: Int = 0 + } + + case object BodyKindResponse extends BodyKind { + def value: Int = 1 + } + + def fromValue(value: Int): BodyKind = { + value match { + case 0 => BodyKindRequest + case 1 => BodyKindResponse + } + } + + def toString(value: BodyKind): String = { + value match { + case BodyKindRequest => "BodyKindRequest" + case BodyKindResponse => "BodyKindResponse" + case _ => throw new Exception("invalid body kind") + } + } +} + +sealed trait LogLevel { + def value: Int +} + +object LogLevel { + case object LogLevelDebug extends LogLevel { + def value: Int = -1 + } + + case object LogLevelInfo extends LogLevel { + def value: Int = 0 + } + + case object LogLevelWarn extends LogLevel { + def value: Int = 1 + } + + case object LogLevelError extends LogLevel { + def value: Int = 2 + } + + case object LogLevelNone extends LogLevel { + def value: Int = 3 + } + + def fromValue(value: Int): LogLevel = { + value match { + case -1 => LogLevelDebug + case 0 => LogLevelInfo + case 1 => LogLevelWarn + case 2 => LogLevelError + case 3 => LogLevelNone + case _ => throw new Exception("invalid log level") + } + } +} + + +sealed trait Feature { + def value: Int +} + +object Feature { + case object FeatureBufferRequest extends Feature { + def value: Int = 1 << 0 + } + + case object FeatureBufferResponse extends Feature { + def value: Int = 1 << 1 + } + + case object FeatureTrailers extends Feature { + def value: Int = 1 << 2 + } + + def toString(feature: Feature): String = { + feature match { + case FeatureBufferRequest => "FeatureBufferRequest" + case FeatureBufferResponse => "FeatureBufferResponse" + case FeatureTrailers => "FeatureTrailers" + case _ => throw new Exception("invalid feature") + } + } +} + +case class Features(f: Int) { + def withEnabled(feature: Int): Features = { + Features(f | feature) + } + + // returns true if the feature (or group of features) is enabled. + def isEnabled(feature: Feature): Boolean = { + (f & feature.value) != 0 + } +} \ No newline at end of file diff --git a/otoroshi/app/wasm/proxywasm/coraza.scala b/otoroshi/app/wasm/proxywasm/coraza.scala index 5761c66e0..9fdfc5c5a 100644 --- a/otoroshi/app/wasm/proxywasm/coraza.scala +++ b/otoroshi/app/wasm/proxywasm/coraza.scala @@ -96,8 +96,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e function: String, params: Parameters, data: VmData, - attrs: TypedMap, - shouldBeCallOnce: Boolean = false + attrs: TypedMap ): Future[Either[JsValue, ResultsWrapper]] = { attrs.get(otoroshi.wasm.proxywasm.CorazaPluginKeys.CorazaWasmVmKey) match { case None => @@ -125,8 +124,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e params: Parameters, results: Int, data: VmData, - attrs: TypedMap, - shouldBeCallOnce: Boolean = false + attrs: TypedMap ): Future[ResultsWrapper] = { attrs.get(otoroshi.wasm.proxywasm.CorazaPluginKeys.CorazaWasmVmKey) match { case None => @@ -160,7 +158,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e def proxyOnVmStart(attrs: TypedMap, rootData: VmData): Future[Boolean] = { val prs = new Parameters(2) .pushInts(0, vmConfigurationSize) - callPluginWithResults("proxy_on_vm_start", prs, 1, rootData, attrs, shouldBeCallOnce = true).map { + callPluginWithResults("proxy_on_vm_start", prs, 1, rootData, attrs).map { proxyOnVmStartAction => val res = proxyOnVmStartAction.results.getValues()(0).v.i32 != 0 proxyOnVmStartAction.free() @@ -171,7 +169,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e def proxyOnConfigure(rootContextId: Int, attrs: TypedMap, rootData: VmData): Future[Boolean] = { val prs = new Parameters(2) .pushInts(rootContextId, pluginConfigurationSize) - callPluginWithResults("proxy_on_configure", prs, 1, rootData, attrs, shouldBeCallOnce = true).map { + callPluginWithResults("proxy_on_configure", prs, 1, rootData, attrs).map { proxyOnConfigureAction => val res = proxyOnConfigureAction.results.getValues()(0).v.i32 != 0 proxyOnConfigureAction.free() @@ -196,7 +194,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e } def proxyStart(attrs: TypedMap, rootData: VmData): Future[ResultsWrapper] = { - callPluginWithoutResults("_start", new Parameters(0), rootData, attrs, shouldBeCallOnce = true).map { res => + callPluginWithoutResults("_start", new Parameters(0), rootData, attrs).map { res => res.right.get } } @@ -206,8 +204,7 @@ class CorazaPlugin(wasm: WasmConfig, val config: CorazaWafConfig, key: String, e "proxy_abi_version_0_2_0", new Parameters(0), rootData, - attrs, - shouldBeCallOnce = true + attrs ).map(_ => ()) } diff --git a/otoroshi/app/wasm/wasm.scala b/otoroshi/app/wasm/wasm.scala index 324be18fa..d84c9d12f 100644 --- a/otoroshi/app/wasm/wasm.scala +++ b/otoroshi/app/wasm/wasm.scala @@ -107,6 +107,7 @@ case class WasmConfig( // lifetime: WasmVmLifetime = WasmVmLifetime.Forever, wasi: Boolean = false, opa: Boolean = false, + httpWasm: Boolean = false, instances: Int = 1, killOptions: WasmVmKillOptions = WasmVmKillOptions.default, authorizations: WasmAuthorizations = WasmAuthorizations() @@ -124,6 +125,7 @@ case class WasmConfig( "allowedPaths" -> allowedPaths, "wasi" -> wasi, "opa" -> opa, + "httpWasm" -> httpWasm, // "lifetime" -> lifetime.json, "authorizations" -> authorizations.json, "instances" -> instances, @@ -168,6 +170,7 @@ object WasmConfig { allowedPaths = (json \ "allowedPaths").asOpt[Map[String, String]].getOrElse(Map.empty), wasi = (json \ "wasi").asOpt[Boolean].getOrElse(false), opa = (json \ "opa").asOpt[Boolean].getOrElse(false), + httpWasm = (json \ "httpWasm").asOpt[Boolean].getOrElse(false), // lifetime = json // .select("lifetime") // .asOpt[String] diff --git a/otoroshi/conf/wasm/httpwasm/guest.wasm b/otoroshi/conf/wasm/httpwasm/guest.wasm new file mode 100644 index 000000000..0deb98ca0 Binary files /dev/null and b/otoroshi/conf/wasm/httpwasm/guest.wasm differ diff --git a/otoroshi/javascript/src/forms/ng_plugins/NgHttpWasm.js b/otoroshi/javascript/src/forms/ng_plugins/NgHttpWasm.js new file mode 100644 index 000000000..a0b8818bc --- /dev/null +++ b/otoroshi/javascript/src/forms/ng_plugins/NgHttpWasm.js @@ -0,0 +1,7 @@ +import WasmPlugin from "./WasmPlugin"; + +export default { + id: 'cp:otoroshi.wasm.httpwasm.NgHttpWasm', + config_schema: WasmPlugin.config_schema, + config_flow: WasmPlugin.config_flow, +}; diff --git a/otoroshi/javascript/src/forms/ng_plugins/WasmPlugin.js b/otoroshi/javascript/src/forms/ng_plugins/WasmPlugin.js index 151f8d0d8..f98de2fe0 100644 --- a/otoroshi/javascript/src/forms/ng_plugins/WasmPlugin.js +++ b/otoroshi/javascript/src/forms/ng_plugins/WasmPlugin.js @@ -104,6 +104,13 @@ const schema = { description: 'The WASM source is an OPA rego policy compiled to WASM', }, }, + httpwasm: { + type: 'box-bool', + label: 'HTTP-WASM', + props: { + description: 'The WASM source is a HTTP WASM', + }, + }, authorizations: { label: 'Host functions authorizations', type: 'form', @@ -219,13 +226,6 @@ const schema = { collapsed: false, flow: ['max_calls', 'max_memory_usage', 'max_avg_call_duration', 'max_unused_duration'], schema: { - max_calls: { - type: 'bool', - label: 'Immortal', - props: { - help: 'The vm instances cannot be killed', - }, - }, max_calls: { type: 'number', label: 'Max calls', @@ -274,6 +274,7 @@ export default { // v.source.kind.toLowerCase() !== 'local' && 'lifetime', v.source.kind.toLowerCase() !== 'local' && 'authorizations', v.source.kind.toLowerCase() !== 'local' && 'killOptions', + 'httpwasm', v.source.kind.toLowerCase() !== 'local' && { type: 'group', name: 'Advanced settings', diff --git a/otoroshi/javascript/src/forms/ng_plugins/index.js b/otoroshi/javascript/src/forms/ng_plugins/index.js index eb7c97d93..83d7bc4eb 100644 --- a/otoroshi/javascript/src/forms/ng_plugins/index.js +++ b/otoroshi/javascript/src/forms/ng_plugins/index.js @@ -143,6 +143,7 @@ import WebsocketSizeValidator from './WebsocketSizeValidator'; import WebsocketTypeValidator from './WebsocketTypeValidator'; import JqWebsocketMessageTransformer from './JqWebsocketMessageTransformer'; import ZipFileBackend from './ZipFileBackend'; +import NgHttpWasm from './NgHttpWasm'; export const Backend = NgBackend; export const Frontend = NgFrontend; @@ -161,6 +162,7 @@ const pluginsArray = [ CanaryMode, ContextValidation, NgCorazaWAF, + NgHttpWasm, Cors, DisableHttp10, EndlessHttpResponse,