Skip to content

Commit

Permalink
merge
Browse files Browse the repository at this point in the history
  • Loading branch information
baudelotphilippe committed Apr 9, 2024
2 parents b227c95 + d2e9a65 commit 85d838d
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 101 deletions.
214 changes: 134 additions & 80 deletions app/fr/maif/izanami/wasm/host.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@ import fr.maif.izanami.wasm.WasmConfig
import io.otoroshi.wasm4s.scaladsl.{EmptyUserData, EnvUserData, HostFunctionWithAuthorization}
import org.extism.sdk.{ExtismCurrentPlugin, ExtismFunction, HostFunction, HostUserData, LibExtism}
import org.extism.sdk.wasmotoroshi._
import play.api.Logger
import play.api.libs.json.{JsValue, Json}
import play.api.libs.typedmap.TypedMap

import java.nio.charset.StandardCharsets
import java.util.Optional
import java.util.concurrent.TimeUnit
import scala.collection.Seq
import scala.concurrent.duration.Duration
import scala.concurrent.{Await, ExecutionContext}

object HFunction {
def defineContextualFunction(
fname: String,
config: WasmConfig
)(
f: (ExtismCurrentPlugin, Array[LibExtism.ExtismVal], Array[LibExtism.ExtismVal], EnvUserData) => Unit
)(implicit env: Env, ec: ExecutionContext, mat: Materializer): HostFunction[EnvUserData] = {
fname: String,
config: WasmConfig
)(
f: (ExtismCurrentPlugin, Array[LibExtism.ExtismVal], Array[LibExtism.ExtismVal], EnvUserData) => Unit
)(implicit env: Env, ec: ExecutionContext, mat: Materializer): HostFunction[EnvUserData] = {
val ev = EnvUserData(env.wasmIntegration.context, ec, mat, config)
defineFunction[EnvUserData](
fname,
Expand All @@ -36,24 +38,24 @@ object HFunction {
}

def defineFunction[A <: EnvUserData](
fname: String,
data: Option[A],
returnType: LibExtism.ExtismValType,
params: LibExtism.ExtismValType*
)(
f: (ExtismCurrentPlugin, Array[LibExtism.ExtismVal], Array[LibExtism.ExtismVal], Option[A]) => Unit
): HostFunction[A] = {
fname: String,
data: Option[A],
returnType: LibExtism.ExtismValType,
params: LibExtism.ExtismValType*
)(
f: (ExtismCurrentPlugin, Array[LibExtism.ExtismVal], Array[LibExtism.ExtismVal], Option[A]) => Unit
): HostFunction[A] = {
new HostFunction[A](
fname,
Array(params: _*),
Array(returnType),
new ExtismFunction[A] {
override def invoke(
plugin: ExtismCurrentPlugin,
params: Array[LibExtism.ExtismVal],
returns: Array[LibExtism.ExtismVal],
data: Optional[A]
): Unit = {
plugin: ExtismCurrentPlugin,
params: Array[LibExtism.ExtismVal],
returns: Array[LibExtism.ExtismVal],
data: Optional[A]
): Unit = {
f(plugin, params, returns, if (data.isEmpty) None else Some(data.get()))
}
},
Expand Down Expand Up @@ -84,82 +86,134 @@ object Utils {
}
}

object LogLevel extends Enumeration {
type LogLevel = Value

val LogLevelTrace, LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError, LogLevelCritical, LogLevelMax = Value
}

object Status extends Enumeration {
type Status = Value

val StatusOK, StatusNotFound, StatusBadArgument, StatusEmpty, StatusCasMismatch, StatusInternalFailure,
StatusUnimplemented = Value
}

object Logging {

val logger = Logger("izanami-wasm-logger")

def proxyLog(): HostFunction[EnvUserData] = HFunction.defineFunction(
"proxy_log",
None,
LibExtism.ExtismValType.I32,
LibExtism.ExtismValType.I32,
LibExtism.ExtismValType.I64,
LibExtism.ExtismValType.I64
) { (plugin, params, returns, data) =>
val logLevel = LogLevel(params(0).v.i32)

val messageData = Utils.rawBytePtrToString(plugin, params(1).v.i64, params(2).v.i64)

logLevel match {
case LogLevel.LogLevelTrace => logger.trace(messageData)
case LogLevel.LogLevelDebug => logger.debug(messageData)
case LogLevel.LogLevelInfo => logger.info(messageData)
case LogLevel.LogLevelWarn => logger.warn(messageData)
case _ => logger.error(messageData)
}

returns(0).v.i32 = Status.StatusOK.id
}

def getFunctions(config: WasmConfig)(implicit
env: Env,
executionContext: ExecutionContext,
mat: Materializer
): Seq[HostFunctionWithAuthorization] = {
Seq(
HostFunctionWithAuthorization(proxyLog(), _ => true)
)
}
}

object HttpCall {
def proxyHttpCall(config: WasmConfig)(implicit env: Env, executionContext: ExecutionContext, mat: Materializer) = {
HFunction.defineContextualFunction("proxy_http_call", config) {
(
plugin: ExtismCurrentPlugin,
params: Array[LibExtism.ExtismVal],
returns: Array[LibExtism.ExtismVal],
hostData: EnvUserData
) => {
val context = Json.parse(Utils.contextParamsToString(plugin, params.toIndexedSeq:_*))

val url = (context \ "url").asOpt[String].getOrElse("https://mirror.otoroshi.io") // TODO
val allowedHosts = hostData.config.allowedHosts // TODO handle valutaion from UI
val urlHost = Uri(url).authority.host.toString()
val allowed = allowedHosts.isEmpty || allowedHosts.contains("*")
if (allowed) {
val builder = env.Ws
.url(url)
.withMethod((context \ "method").asOpt[String].getOrElse("GET"))
.withHttpHeaders((context \ "headers").asOpt[Map[String, String]].getOrElse(Map.empty).toSeq: _*)
.withRequestTimeout(
Duration(
(context \ "request_timeout").asOpt[Long].getOrElse(30000L), // TODO
TimeUnit.MILLISECONDS
plugin: ExtismCurrentPlugin,
params: Array[LibExtism.ExtismVal],
returns: Array[LibExtism.ExtismVal],
hostData: EnvUserData
) =>
{
val context = Json.parse(Utils.contextParamsToString(plugin, params.toIndexedSeq: _*))

val url = (context \ "url").asOpt[String].getOrElse("https://mirror.otoroshi.io") // TODO
val allowedHosts = hostData.config.allowedHosts // TODO handle valutaion from UI
val urlHost = Uri(url).authority.host.toString()
val allowed = allowedHosts.isEmpty || allowedHosts.contains("*")
if (allowed) {
val builder = env.Ws
.url(url)
.withMethod((context \ "method").asOpt[String].getOrElse("GET"))
.withHttpHeaders((context \ "headers").asOpt[Map[String, String]].getOrElse(Map.empty).toSeq: _*)
.withRequestTimeout(
Duration(
(context \ "request_timeout").asOpt[Long].getOrElse(30000L), // TODO
TimeUnit.MILLISECONDS
)
)
.withFollowRedirects((context \ "follow_redirects").asOpt[Boolean].getOrElse(false))
.withQueryStringParameters((context \ "query").asOpt[Map[String, String]].getOrElse(Map.empty).toSeq: _*)
val bodyAsBytes = context.select("body_bytes").asOpt[Array[Byte]].map(bytes => ByteString(bytes))
val bodyBase64 = context.select("body_base64").asOpt[String].map(str => ByteString(str).decodeBase64)
val bodyJson = context.select("body_json").asOpt[JsValue].map(str => ByteString(str.stringify))
val bodyStr = context
.select("body_str")
.asOpt[String]
.orElse(context.select("body").asOpt[String])
.map(str => ByteString(str))
val body: Option[ByteString] = bodyStr.orElse(bodyJson).orElse(bodyBase64).orElse(bodyAsBytes)
val request = body match {
case Some(bytes) => builder.withBody(bytes)
case None => builder
}
val out = Await.result(
request
.execute()
.map { res =>
val body = res.bodyAsBytes.encodeBase64.utf8String
val headers = res.headers.view.mapValues(_.head)
Json.obj(
"status" -> res.status,
"headers" -> headers,
"body_base64" -> body
)
},
Duration(1, TimeUnit.MINUTES) // TODO
)
.withFollowRedirects((context \ "follow_redirects").asOpt[Boolean].getOrElse(false))
.withQueryStringParameters((context \ "query").asOpt[Map[String, String]].getOrElse(Map.empty).toSeq: _*)
val bodyAsBytes = context.select("body_bytes").asOpt[Array[Byte]].map(bytes => ByteString(bytes))
val bodyBase64 = context.select("body_base64").asOpt[String].map(str => ByteString(str).decodeBase64)
val bodyJson = context.select("body_json").asOpt[JsValue].map(str => ByteString(str.stringify))
val bodyStr = context
.select("body_str")
.asOpt[String]
.orElse(context.select("body").asOpt[String])
.map(str => ByteString(str))
val body: Option[ByteString] = bodyStr.orElse(bodyJson).orElse(bodyBase64).orElse(bodyAsBytes)
val request = body match {
case Some(bytes) => builder.withBody(bytes)
case None => builder
}
val out = Await.result(
request
.execute()
.map { res =>
val body = res.bodyAsBytes.encodeBase64.utf8String
val headers = res.headers.view.mapValues(_.head)
plugin.returnString(returns(0), Json.stringify(out))
} else {
plugin.returnString(
returns(0),
Json.stringify(
Json.obj(
"status" -> res.status,
"headers" -> headers,
"body_base64" -> body
"status" -> 403,
"headers" -> Json.obj("content-type" -> "text/plain"),
"body_base64" -> ByteString(s"you cannot access host: ${urlHost}").encodeBase64.utf8String
)
},
Duration(1, TimeUnit.MINUTES) // TODO
)
plugin.returnString(returns(0), Json.stringify(out))
} else {
plugin.returnString(
returns(0),
Json.stringify(
Json.obj(
"status" -> 403,
"headers" -> Json.obj("content-type" -> "text/plain"),
"body_base64" -> ByteString(s"you cannot access host: ${urlHost}").encodeBase64.utf8String
)
)
)
}
}
}
}
}

def getFunctions(config: WasmConfig, attrs: Option[TypedMap])(implicit
env: Env,
executionContext: ExecutionContext,
mat: Materializer
env: Env,
executionContext: ExecutionContext,
mat: Materializer
): Seq[HostFunctionWithAuthorization] = {
Seq(
HostFunctionWithAuthorization(proxyHttpCall(config), _.asInstanceOf[WasmConfig].authorizations.httpAccess)
Expand All @@ -170,8 +224,8 @@ object HttpCall {
object HostFunctions {

def getFunctions(config: WasmConfig, pluginId: String, attrs: Option[TypedMap])(implicit
env: Env,
executionContext: ExecutionContext
env: Env,
executionContext: ExecutionContext
): Array[HostFunction[_ <: HostUserData]] = {

implicit val mat = env.materializer
Expand Down
53 changes: 32 additions & 21 deletions app/fr/maif/izanami/wasm/wasm.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import scala.util.{Failure, Success, Try}

case class WasmAuthorizations(
httpAccess: Boolean = false
) {
) {
def json: JsValue = WasmAuthorizations.format.writes(this)
}

Expand All @@ -42,15 +42,15 @@ case class WasmConfigWithFeatures(wasmConfig: WasmConfig, features: Seq[WasmScri
object WasmConfigWithFeatures {
implicit val wasmConfigAssociatedFeaturesWrites: Writes[WasmScriptAssociatedFeatures] = { feature =>
Json.obj(
"name" -> feature.name,
"name" -> feature.name,
"project" -> feature.project,
"id" -> feature.id
"id" -> feature.id
)
}

implicit val wasmConfigWithFeaturesWrites: Writes[WasmConfigWithFeatures] = { wasm =>
Json.obj(
"config" -> Json.toJson(wasm.wasmConfig)(WasmConfig.format),
"config" -> Json.toJson(wasm.wasmConfig)(WasmConfig.format),
"features" -> wasm.features
)
}
Expand All @@ -73,7 +73,7 @@ case class WasmConfig(
authorizations: WasmAuthorizations = WasmAuthorizations()
) extends WasmConfiguration {
// still here for compat reason
def json: JsValue = Json.obj(
def json: JsValue = Json.obj(
"name" -> name,
"source" -> source.json,
"memoryPages" -> memoryPages,
Expand Down Expand Up @@ -108,8 +108,10 @@ object WasmConfig {
case Some(source) => WasmSource(WasmSourceKind.Wasmo, source, Json.obj("name" -> name))
case None =>
rawSource match {
case Some(source) if source.startsWith("http://") => WasmSource(WasmSourceKind.Http, source, Json.obj("name" -> name))
case Some(source) if source.startsWith("https://") => WasmSource(WasmSourceKind.Http, source,Json.obj("name" -> name))
case Some(source) if source.startsWith("http://") =>
WasmSource(WasmSourceKind.Http, source, Json.obj("name" -> name))
case Some(source) if source.startsWith("https://") =>
WasmSource(WasmSourceKind.Http, source, Json.obj("name" -> name))
case Some(source) if source.startsWith("file://") =>
WasmSource(WasmSourceKind.File, source.replace("file://", ""), Json.obj("name" -> name))
case Some(source) if source.startsWith("base64://") =>
Expand Down Expand Up @@ -159,47 +161,56 @@ object WasmConfig {
}

object WasmUtils {
def handle(config: WasmConfig, requestContext: RequestContext)(implicit ec: ExecutionContext, env: Env): Future[Either[IzanamiError, Boolean]] = {
def handle(config: WasmConfig, requestContext: RequestContext)(implicit
ec: ExecutionContext,
env: Env
): Future[Either[IzanamiError, Boolean]] = {
val context = (requestContext.wasmJson.as[JsObject] ++ Json.obj(
"id" -> requestContext.user, "context" -> requestContext.data, "executionContext" -> requestContext.context.elements
"id" -> requestContext.user,
"context" -> requestContext.data,
"executionContext" -> requestContext.context.elements
)).stringify
env.wasmIntegration.withPooledVm(config) { vm =>
if (config.opa) {
vm.callOpa("execute", context).map {
case Left(err) => throw new RuntimeException(s"Failed to execute wasm feature : ${err.toString()}") // TODO - fix me
case Left(err) =>
throw new RuntimeException(s"Failed to execute wasm feature : ${err.toString()}") // TODO - fix me
case Right((rawResult, _)) => {
val response = Json.parse(rawResult)
val result = response.asOpt[JsArray].getOrElse(Json.arr())
(result.value.head \ "result").asOpt[Boolean]
val result = response.asOpt[JsArray].getOrElse(Json.arr())
(result.value.head \ "result")
.asOpt[Boolean]
.orElse((result.value.head \ "result").asOpt[String].flatMap(s => s.toBooleanOption))
.toRight({
.toRight {
env.logger.error(s"Failed to parse wasm result (OPA), result is $result")
WasmError()
})
}
}
}
} else {
vm.callExtismFunction("execute", context).map {
case Left(err) => throw new RuntimeException(s"Failed to execute wasm feature : ${err.toString()}") // TODO - fix me
case Left(err) =>
throw new RuntimeException(s"Failed to execute wasm feature : ${err.toString()}") // TODO - fix me
case Right(rawResult) => {
if (rawResult.startsWith("{")) {
val response = Json.parse(rawResult)

(response \ "active").asOpt[Boolean]
(response \ "active")
.asOpt[Boolean]
.orElse((response \ "active").asOpt[String].flatMap(s => s.toBooleanOption))
.toRight({
.toRight {
env.logger.error(s"Failed to parse wasm result, result is $response")
WasmError()
})
}
} else {
rawResult.toBooleanOption.toRight({
rawResult.toBooleanOption.toRight {
env.logger.error(s"Failed to parse wasm result, result is $rawResult")
WasmError()
})
}
}
}
}
}
}
}
}
}

0 comments on commit 85d838d

Please sign in to comment.