Using wasm plugins

WebAssembly (WASM) is a simple machine model and executable format with an extensive specification. It is designed to be portable, compact, and execute at or near native speeds. Otoroshi already supports the execution of WASM files by providing different plugins that can be applied on routes. You can find more about those plugins here

To simplify the process of WASM creation and usage, Otoroshi provides:

  • otoroshi ui integration: a full set of plugins that let you pick which WASM function to runtime at any point in a route
  • otoroshi wasmo: a code editor in the browser that let you write your plugin in Rust, TinyGo, Javascript or Assembly Script without having to think about compiling it to WASM (you can find a complete tutorial about it here)

Tutorial

  1. Before your start
  2. Create the route with the plugin validator
  3. Test your validator
  4. Update the route by replacing the backend with a WASM file
  5. WASM backend test
  6. Expose a single file as WASM backend

After completing these steps you will have a route that uses WASM plugins written in Rust.

Before your start

If you already have an up and running otoroshi instance, you can skip the following instructions

Set up an Otoroshi

Let’s start by downloading the latest Otoroshi.

curl -L -o otoroshi.jar 'https://github.com/MAIF/otoroshi/releases/download/v16.15.4/otoroshi.jar'

then you can run start Otoroshi :

java -Dotoroshi.adminPassword=password -jar otoroshi.jar 

Now you can log into Otoroshi at http://otoroshi.oto.tools:8080 with admin@otoroshi.io/password

Create a new route, exposed on http://myservice.oto.tools:8080, which will forward all requests to the mirror https://mirror.otoroshi.io. Each call to this service will returned the body and the headers received by the mirror.

curl -X POST 'http://otoroshi-api.oto.tools:8080/api/routes' \
-H "Content-type: application/json" \
-u admin-api-apikey-id:admin-api-apikey-secret \
-d @- <<'EOF'
{
  "name": "my-service",
  "frontend": {
    "domains": ["myservice.oto.tools"]
  },
  "backend": {
    "targets": [
      {
        "hostname": "mirror.otoroshi.io",
        "port": 443,
        "tls": true
      }
    ]
  }
}
EOF

Create the route with the plugin validator

For this tutorial, we will start with an existing wasm file. The main function of this file will check the value of an http header to allow access or not. The can find this file at https://raw.githubusercontent.com/MAIF/otoroshi/master/demos/wasm/first-validator.wasm

The main function of this validator, written in rust, should look like:

validator.rs
mod types;

use extism_pdk::*;

#[plugin_fn]
pub fn execute(Json(context): Json<types::WasmAccessValidatorContext>) -> FnResult<Json<types::WasmAccessValidatorResponse>> {
  let out = types::WasmAccessValidatorResponse { 
  result: false, 
  error: Some(types::WasmAccessValidatorError { 
      message: "you're not authorized".to_owned(),  
      status: 401
    })  
  };

  match context.request.headers.get("foo") {
      Some(foo) => if foo == "bar" {
        Ok(Json(types::WasmAccessValidatorResponse { 
          result: true,
          error: None
        }))
      } else {
        Ok(Json(types::WasmAccessValidatorResponse { 
          result: false, 
          error: Some(types::WasmAccessValidatorError { 
              message: format!("{} is not authorized", foo).to_owned(),  
              status: 401
            })  
          }))
      },
      None => Ok(Json(out))
  }

}
validator.js
export function execute() {
  let context = JSON.parse(Host.inputString());

    if (context.request.headers["foo"] === "bar") {
        const out = {
            result: true
        };
        Host.outputString(JSON.stringify(out));
    } else {
        const error = {
            result: false,
            error: {
                message: "you're not authorized",
                status: 401
            }
        };
        Host.outputString(JSON.stringify(error));
    }

    return 0;
}
validator.ts
import { WasmAccessValidatorContext, WasmAccessValidatorResponse } from './types';

export declare var Host: any;

export function execute() {
    let context = JSON.parse(Host.inputString()) as WasmAccessValidatorContext;

    if (context.request.headers["foo"] === "bar") {
        const out: WasmAccessValidatorResponse = {
            result: true
        };
        Host.outputString(JSON.stringify(out));
    } else {
        const error: WasmAccessValidatorResponse = {
            result: false,
            error: {
                message: "you're not authorized",
                status: 401
            }
        };
        Host.outputString(JSON.stringify(error));
    }

    return 0;
}
validator.js
export function execute() {
  let context = JSON.parse(Host.inputString());

    if (context.request.headers["foo"] === "bar") {
        const out = {
            result: true
        };
        Host.outputString(JSON.stringify(out));
    } else {
        const error = {
            result: false,
            error: {
                message: "you're not authorized",
                status: 401
            }
        };
        Host.outputString(JSON.stringify(error));
    }

    return 0;
}
validator.go
package main

import (
    "github.com/extism/go-pdk"
    "github.com/buger/jsonparser"
)

//export execute
func execute() int32 {
    input := pdk.Input()

    var foo, err = jsonparser.GetString(input, "request", "headers", "foo")

    if err != nil {}

    var output = ""
  
    if foo == "bar" {
      output = `{
        "result": true
      }`
    } else {
      output = `{
        "result": false,
        "error": {
          "message": "you're not authorized",
          "status": 401
        }
      }`
    }
    
    mem := pdk.AllocateString(output)
    pdk.OutputMemory(mem)

    return 0
}

func main() {}

The plugin receives the request context from Otoroshi (the matching route, the api key if present, the headers, etc) as WasmAccessValidatorContext object. Then it applies a check on the headers, and responds with an error or success depending on the content of the foo header. Obviously, the previous snippet is an example and the editor allows you to write whatever you want as a check.

Let’s create a route that uses the previous wasm file as an access validator plugin :

curl -X POST "http://otoroshi-api.oto.tools:8080/api/routes" \
-H "Content-type: application/json" \
-u admin-api-apikey-id:admin-api-apikey-secret \
-d @- <<'EOF'
{
  "id": "demo-otoroshi",
  "name": "demo-otoroshi",
  "frontend": {
    "domains": ["demo-otoroshi.oto.tools"]
  },
  "backend": {
    "targets": [
      {
        "hostname": "mirror.otoroshi.io",
        "port": 443,
        "tls": true
      }
    ],
    "load_balancing": {
      "type": "RoundRobin"
    }
  },
  "plugins": [
    {
      "plugin": "cp:otoroshi.next.plugins.OverrideHost",
      "enabled": true
    },
    {
      "plugin": "cp:otoroshi.next.plugins.WasmAccessValidator",
      "enabled": true,
      "config": {
        "source": {
          "kind": "http",
          "path": "https://raw.githubusercontent.com/MAIF/otoroshi/master/demos/wasm/first-validator.wasm",
          "opts": {}
        },
        "memoryPages": 4,
        "functionName": "execute"
      }
    }
  ]
}
EOF

This request will apply the following process:

  • names the route demo-otoroshi
  • creates a frontend exposed on the demo-otoroshi.oto.tools
  • forward requests on one target, reachable at mirror.otoroshi.io using TLS on port 443
  • adds the WasmAccessValidator plugin to validate access based on the foo header to the route

You can validate the route creation by navigating to the dashboard

Test your validator

curl "http://demo-otoroshi.oto.tools:8080" -I

This should output the following error:

HTTP/1.1 401 Unauthorized

Let’s call again the route by adding the header foo with the bar value.

curl "http://demo-otoroshi.oto.tools:8080" -H "foo:bar" -I

This should output the successfull message:

HTTP/1.1 200 OK

Update the route by replacing the backend with a WASM file

The next step in this tutorial is to use a WASM file as backend of the route. We will use an existing WASM file, available in our wasm demos repository on github. The content of this plugin, called wasm-target.wasm, looks like:

target.rs
// (1)
mod types;

// (2)
use extism_pdk::*;
use std::collections::HashMap;

#[plugin_fn]
pub fn execute(Json(context): Json<types::WasmQueryContext>) -> FnResult<Json<types::WasmQueryResponse>> {
    // (3)
    let mut headers = HashMap::new();
    headers.insert("foo".to_string(), "bar".to_string());

    // (4)
    let response = types::WasmQueryResponse { 
        headers: Some(headers.into_iter().chain(context.raw_request.headers).collect()), 
        body: "{\"foo\": \"bar\"}".to_owned(),
        status: 200
    };
  
    Ok(Json(response))
}
target.js
// (2)
export function execute() {
  let str = Host.inputString();
  let context = JSON.parse(str);
  
  // (3)
  let headers = { ...context.request.headers };
  headers["foo"] = "bar";

  // (4)
  let response = {
    headers,
    body: "{\"foo\": \"bar\"}",
    status: 200
  };
  
  Host.outputString(JSON.stringify(response));

  return 0;
}
target.ts
// (1)
import { WasmQueryContext, WasmQueryResponse } from './types';

export declare var Host: any;

// (2)
export function execute() {
    const context = JSON.parse(Host.inputString()) as WasmQueryContext;

    // (3)
    const headers = {
      "foo": "bar",
      ...(context.request.headers || {})
    }

    // (4)
    const response: WasmQueryResponse = {
        headers,
        status: 200,
        body: "{\"foo\": \"bar\"}"
    };
    Host.outputString(JSON.stringify(response));

    return 0;
}
target.go
package main

// (1)
import (
    "github.com/extism/go-pdk"
    "github.com/buger/jsonparser"
)

// (2)
//export execute
func execute() int32 {
    input := pdk.Input() 

    // (3)
    var headers, dataType, offset, err = jsonparser.Get(input, "request", "headers")

    _ = dataType
    _ = offset

    if err != nil {}

    // (4)
    output := `{"headers":` +  string(headers) + `, "body": {\"foo\": \"bar\"}, "status": 200 }`
    mem := pdk.AllocateString(output)
    pdk.OutputMemory(mem)

    return 0
}

func main() {}

Let’s explain this snippet. The purpose of this type of plugin is to respond an HTTP response with http status, body and headers map.

  1. Includes all public structures from types.rs file. This file contains predefined Otoroshi structures that plugins can manipulate.
  2. Necessary imports. Extism’s goal is to make all software programmable by providing a plug-in system.
  3. Creates a map of new headers that will be merged with incoming request headers.
  4. Creates the response object with the map of merged headers, a simple JSON body and a successfull status code.

The file is downloadable here.

Let’s update the route using the this wasm file.

curl -X PUT "http://otoroshi-api.oto.tools:8080/api/routes/demo-otoroshi" \
-H "Content-type: application/json" \
-u admin-api-apikey-id:admin-api-apikey-secret \
-d @- <<'EOF'
{
  "id": "demo-otoroshi",
  "name": "demo-otoroshi",
  "frontend": {
    "domains": ["demo-otoroshi.oto.tools"]
  },
  "backend": {
    "targets": [
      {
        "hostname": "mirror.otoroshi.io",
        "port": 443,
        "tls": true
      }
    ],
    "load_balancing": {
      "type": "RoundRobin"
    }
  },
  "plugins": [
    {
      "plugin": "cp:otoroshi.next.plugins.OverrideHost",
      "enabled": true
    },
    {
      "plugin": "cp:otoroshi.next.plugins.WasmAccessValidator",
      "enabled": true,
      "config": {
         "source": {
          "kind": "http",
          "path": "https://raw.githubusercontent.com/MAIF/otoroshi/master/demos/wasm/first-validator.wasm",
          "opts": {}
        },
        "memoryPages": 4,
        "functionName": "execute"
      }
    },
    {
      "plugin": "cp:otoroshi.next.plugins.WasmBackend",
      "enabled": true,
      "config": {
         "source": {
          "kind": "http",
          "path": "https://raw.githubusercontent.com/MAIF/otoroshi/master/demos/wasm/wasm-target.wasm",
          "opts": {}
        },
        "memoryPages": 4,
        "functionName": "execute"
      }
    }
  ]
}
EOF

The response should contains the updated route content.

WASM backend test

Let’s call our route.

curl "http://demo-otoroshi.oto.tools:8080" -H "foo:bar" -H "fifi: foo" -v

This should output:

*   Trying 127.0.0.1:8080...
* Connected to demo-otoroshi.oto.tools (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: demo-otoroshi.oto.tools:8080
> User-Agent: curl/7.79.1
> Accept: */*
> foo:bar
> fifi:foo
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< foo: bar
< Host: demo-otoroshi.oto.tools:8080
<
* Closing connection 0
{"foo": "bar"}

In this response, we can find our headers send in the curl command and those added by the wasm plugin.

Expose a single file as WASM backend

A WASM backend plugin can directly expose a file written in Wasmo. This is the simplest possibility to write HTML, Javascript, JSON or expose a simple PNG image.

Let’s expose a HTML page. In your Wasmo instance, execute the following instructions:

  1. Click the new plugin button
  2. Add a name and validate
  3. Click the new plugin
  4. Create a new file named index.html
  5. Copy and paste the following content
<!DOCTYPE html>
<html>
  <head>
    <title>Wasmo plugin</title>
  </head>
  <body>
    <h1>Hello from Wasmo</h1>
  </body>
</html>

This snippet is a short HTML template with a title to indicate that it comes from Wasmo.

Now we can write our javascript function to parse and return the content of our HTML to Otoroshi.

  1. Navigate to the index.js file
  2. Replace the content with the following content
import IndexPage from "./index.html";

export function execute() {
  let response = {
    headers: {
      "Content-Type": "text/html; charset=utf-8",
    },
    body: IndexPage,
    status: 200,
  };

  Host.outputString(JSON.stringify(response));

  return 0;
}

The code is pretty self-explanatory. We start by importing our HTML page and we build the response with the correct content type, the body and a 200 http status.

Just before testing, we need to change the esbuild configuration to specify how to bundle the HTML file.

The contents of your esbuild.js file should look this:

const esbuild = require("esbuild");

esbuild.build({
  entryPoints: ["index.js"],
  outdir: "dist",
  bundle: true,
  loader: {
    ".html": "text",
  },
  sourcemap: true,
  minify: false, // might want to use true for production build
  format: "cjs", // needs to be CJS for now
  target: ["es2020"], // don't go over es2020 because quickjs doesn't support it
});

Check your browser at http://demo-otoroshi.oto.tools:8080 and you should see your page content updated to the new text.

If you need to expose more than a HTML page, we highly recommend to use the Zip Backend plugin