Build WASI plugins for the Space Data Network server
SDN plugins are WebAssembly modules compiled to the WASI target. They are loaded at runtime by the Go server via Wazero, a pure-Go WebAssembly runtime with zero dependencies.
Write plugins in C, C++, Rust, Zig, or any language that compiles to WASI.
Plugins run inside the WASM sandbox. No filesystem, no network, no system calls.
Plugins call back into the host for time, cryptographic randomness, and logging.
One .wasm file runs on any OS and architecture the Go server supports.
git clone https://github.com/DigitalArsenal/sdn-plugin-template.git my-plugin
cd my-plugin
Download from wasi-sdk releases and extract to ~/wasi-sdk:
# macOS (Apple Silicon)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-arm64-macos.tar.gz
tar xf wasi-sdk-25.0-arm64-macos.tar.gz && mv wasi-sdk-25.0-arm64-macos ~/wasi-sdk
# macOS (Intel)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-macos.tar.gz
tar xf wasi-sdk-25.0-x86_64-macos.tar.gz && mv wasi-sdk-25.0-x86_64-macos ~/wasi-sdk
# Linux (x86_64)
curl -LO https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-x86_64-linux.tar.gz
tar xf wasi-sdk-25.0-x86_64-linux.tar.gz && mv wasi-sdk-25.0-x86_64-linux ~/wasi-sdk
mkdir -p build-wasi && cd build-wasi
cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/wasi-sdk.cmake -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)
file my-sdn-plugin.wasm
# WebAssembly (wasm) binary module version 0x1 (MVP)
wasm-objdump -x my-sdn-plugin.wasm | grep "plugin_\|malloc\|free"
# func[N] <plugin_init> -> "plugin_init"
# func[N] <plugin_get_public_key> -> "plugin_get_public_key"
# func[N] <plugin_get_metadata> -> "plugin_get_metadata"
# func[N] <plugin_handle_request> -> "plugin_handle_request"
# func[N] <malloc> -> "malloc"
# func[N] <free> -> "free"
The lifecycle of a plugin:
.wasm file and instantiates a Wazero runtimesdn.clock_now_ms, sdn.random_bytes, sdn.log) are registeredplugin_init is called with a binary config blobplugin_handle_requestEvery SDN WASI plugin must export these C functions. Use __attribute__((export_name("..."))) in C/C++ to ensure the symbols are exported.
| Export | Signature | Description |
|---|---|---|
malloc required |
(i32) → i32 |
Standard C allocator. The host writes data into plugin memory via this. |
free required |
(i32) |
Standard C deallocator. The host frees after each call. |
plugin_init required |
(i32, i32) → i32 |
Called once with (config_ptr, config_len). Return 0 on success. |
plugin_get_public_key required |
(i32, i32) → i32 |
Write public identity to (output_ptr, output_size). Return bytes written or -1. |
plugin_get_metadata required |
(i32, i32) → i32 |
Write metadata to (output_ptr, output_size). Return bytes written or -1. |
plugin_handle_request required |
(i32, i32, i32, i32, i32, i32) → i32 |
Process request. Params: (req_ptr, req_len, host_ptr, out_ptr, out_size, out_len_ptr). Return status (0 = OK). |
__attribute__((export_name("plugin_init")))
int32_t plugin_init(const uint8_t* config_ptr, size_t config_len) {
// Parse config, initialize state
return 0;
}
The binary blob passed to plugin_init is plugin-specific. You define whatever format makes sense. The Go wrapper on the host side packs the config before calling init. Document your format so others can write host wrappers for different runtimes.
Functions that return status codes should use 0 for success. Non-zero values are plugin-defined error codes. The host logs non-zero statuses for debugging.
The host provides these functions in the sdn WASI module namespace. Include host_imports.h to declare them. All imports are optional — only declare what your plugin uses.
| Import | Signature | Description |
|---|---|---|
sdn.clock_now_ms |
() → i64 |
Current wall-clock time in milliseconds since Unix epoch |
sdn.random_bytes |
(i32, i32) → i32 |
Fill (ptr, len) with cryptographic random bytes. Returns 0 on success, -1 on error. |
sdn.log |
(i32, i32, i32) |
Log message at (level, ptr, len). Levels: 0=debug, 1=info, 2=warn, 3+=error |
#include "host_imports.h"
// Use directly:
int64_t now = sdn_clock_now_ms();
sdn_random_bytes(buffer, 32);
sdn_log(1, (const uint8_t*)"hello", 5);
#[link(wasm_import_module = "sdn")]
extern "C" {
fn clock_now_ms() -> i64;
fn random_bytes(ptr: *mut u8, len: u32) -> i32;
fn log(level: i32, ptr: *const u8, len: u32);
}
WASM plugins have their own linear memory. The host and plugin share data through this memory using malloc/free.
malloc(size) to allocate space in plugin memoryfree() on all allocationsThe host pre-allocates several buffers before calling plugin_handle_request:
req_ptr / req_len — the request packet (host writes, you read)host_ptr — NUL-terminated host/origin string (host writes, you read)out_ptr / out_size — output buffer for your response (you write)out_len_ptr — 4-byte location where you write the actual response length as a uint32 LE// Inside plugin_handle_request:
// Write your response
memcpy(output_buffer, response_data, response_len);
// Tell the host how many bytes you wrote
uint32_t len = (uint32_t)response_len;
memcpy(output_len, &len, 4); // 4-byte LE
return 0; // success
On the server side, each WASI plugin needs a thin Go wrapper that implements the plugins.Plugin interface.
type Plugin interface {
ID() string
Start(ctx context.Context, runtime RuntimeContext) error
RegisterRoutes(mux *http.ServeMux)
Close() error
}
package myplugin
import (
"context"
"os"
"github.com/spacedatanetwork/sdn-server/internal/wasiplugin"
"github.com/spacedatanetwork/sdn-server/plugins"
)
const ID = "my-plugin"
type Plugin struct {
runtime *wasiplugin.Runtime
handler *wasiplugin.Handler
wasmPath string
}
func New(wasmPath string) *Plugin {
return &Plugin{wasmPath: wasmPath}
}
func (p *Plugin) ID() string { return ID }
func (p *Plugin) Start(ctx context.Context, rc plugins.RuntimeContext) error {
wasmBytes, err := os.ReadFile(p.wasmPath)
if err != nil {
return err
}
rt, err := wasiplugin.New(ctx, wasmBytes)
if err != nil {
return err
}
config := packYourConfig() // your binary config format
if err := rt.Init(ctx, config); err != nil {
rt.Close(ctx)
return err
}
p.runtime = rt
p.handler = wasiplugin.NewHandler(rt)
return nil
}
func (p *Plugin) RegisterRoutes(mux *http.ServeMux) {
if p.handler == nil { return }
mux.HandleFunc("/my-plugin/v1/data", p.handler.HandlePublicKey)
mux.HandleFunc("/my-plugin/v1/exchange", p.handler.HandleKeyExchange)
}
func (p *Plugin) Close() error {
if p.runtime != nil {
return p.runtime.Close(context.Background())
}
return nil
}
if wasmPath := findMyPluginWasm(); wasmPath != "" {
p := myplugin.New(wasmPath)
if err := n.plugins.Register(p); err != nil {
log.Warnf("plugin %q: %v", myplugin.ID, err)
}
}
Plugins can provide a web interface that appears on the Plugins page in the SDN web client. The UI is rendered inside a sandboxed iframe, so you have full control over layout and styling.
UIProvider interfaceGET /api/v1/plugins/manifestImplement this optional Go interface to declare your plugin's UI metadata:
// UIDescriptor describes a plugin's web UI.
type UIDescriptor struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"` // emoji or single character
Color string `json:"color,omitempty"` // CSS background for icon badge
TextColor string `json:"textColor,omitempty"` // CSS text color for icon badge
URL string `json:"url,omitempty"` // path to plugin UI page
}
// UIProvider is an optional interface plugins can implement.
type UIProvider interface {
UIDescriptor() plugins.UIDescriptor
}
func (p *Plugin) UIDescriptor() plugins.UIDescriptor {
return plugins.UIDescriptor{
Title: "My Plugin",
Description: "Does something cool",
Icon: "⚡",
Color: "#fef3c7",
TextColor: "#92400e",
URL: "/my-plugin/v1/ui",
}
}
// Serve the UI HTML page
func (p *Plugin) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/my-plugin/v1/ui", p.handleUI)
// ... other routes
}
func (p *Plugin) handleUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<body>
<h2>My Plugin</h2>
<p>Status: Running</p>
</body>
</html>`)
}
The plugin manager automatically exposes GET /api/v1/plugins/manifest which returns JSON like:
[
{
"id": "my-plugin",
"version": "1.0.0",
"status": "running",
"description": "Does something cool",
"ui": {
"title": "My Plugin",
"description": "Does something cool",
"icon": "⚡",
"color": "#fef3c7",
"textColor": "#92400e",
"url": "/my-plugin/v1/ui"
}
}
]
Beyond UIProvider, the plugin manager also checks for these optional interfaces via type assertion:
| Interface | Method | Description |
|---|---|---|
UIProvider | UIDescriptor() UIDescriptor | Provides web UI metadata for the Plugins page |
| Version | Version() string | Semantic version shown on plugin card |
| Description | Description() string | Short description shown on plugin card |
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"C++ plugins work but require extra build configuration because WASI lacks threading, full POSIX signals, and C++ exception support.
Uncomment these lines in CMakeLists.txt:
# Enable exceptions + RTTI (needed by Crypto++, Boost, etc.)
string(REPLACE "-fno-exceptions" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
string(REPLACE "-fno-rtti" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fexceptions -frtti -D_WASI_EMULATED_SIGNAL")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -D_WASI_EMULATED_SIGNAL")
Add the exception stubs to your source list:
set(PLUGIN_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/src/my_plugin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/wasi-stubs/cxa_stubs.cpp
)
Uncomment in the linker flags:
target_link_options(sdn_plugin PRIVATE -lwasi-emulated-signal)
Use CMake FetchContent for C++ libraries. Important: set override flags before FetchContent_MakeAvailable so dependencies inherit your WASI-compatible settings.
include(FetchContent)
FetchContent_Declare(cryptopp_cmake
GIT_REPOSITORY https://github.com/abdes/cryptopp-cmake.git
GIT_TAG 604d3df147e7b16fb7caa70e22c54c9a40ac1bd5
GIT_SHALLOW TRUE)
# Disable ASM — not available in WASM
set(CRYPTOPP_DISABLE_ASM ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(cryptopp_cmake)
target_link_libraries(sdn_plugin PRIVATE cryptopp)
The src/wasi-stubs/ directory provides headers that shadow missing POSIX features:
| Stub | Shadows | Why |
|---|---|---|
mutex | <mutex> | No-op mutex/lock_guard for single-threaded WASM |
setjmp.h | <setjmp.h> | Stub setjmp/longjmp used by crypto libs for SIMD detection |
signal.h | <signal.h> | Adds sigset_t/sigprocmask missing from WASI emulated signals |
cxa_stubs.cpp | C++ exception ABI | Provides __cxa_throw etc. as abort() — exceptions become WASM traps |
Rust has first-class WASI support. Target wasm32-wasip1:
[package]
name = "my-sdn-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
# Output: target/wasm32-wasip1/release/my_sdn_plugin.wasm
use std::alloc::{alloc, dealloc, Layout};
#[link(wasm_import_module = "sdn")]
extern "C" {
fn clock_now_ms() -> i64;
fn random_bytes(ptr: *mut u8, len: u32) -> i32;
fn log(level: i32, ptr: *const u8, len: u32);
}
fn log_info(msg: &str) {
unsafe { log(1, msg.as_ptr(), msg.len() as u32) };
}
#[no_mangle]
pub extern "C" fn plugin_init(config_ptr: *const u8, config_len: usize) -> i32 {
if config_ptr.is_null() || config_len == 0 {
return 1;
}
let _config = unsafe { std::slice::from_raw_parts(config_ptr, config_len) };
log_info("rust plugin initialized");
0
}
#[no_mangle]
pub extern "C" fn plugin_get_public_key(output: *mut u8, output_size: usize) -> i32 {
if output.is_null() || output_size < 32 { return -1; }
unsafe { random_bytes(output, 32) };
32
}
#[no_mangle]
pub extern "C" fn plugin_get_metadata(output: *mut u8, output_size: usize) -> i32 {
if output.is_null() || output_size < 4 { return -1; }
unsafe { std::ptr::write_bytes(output, 0, 4) };
4
}
#[no_mangle]
pub extern "C" fn plugin_handle_request(
req_ptr: *const u8, req_len: usize,
_host_ptr: *const u8,
out_ptr: *mut u8, out_size: usize,
out_len_ptr: *mut usize,
) -> i32 {
if out_ptr.is_null() || out_len_ptr.is_null() { return 1; }
let now = unsafe { clock_now_ms() };
let resp_len = 8 + req_len;
if out_size < resp_len {
unsafe { *out_len_ptr = 0 };
return 7;
}
unsafe {
std::ptr::copy_nonoverlapping(
now.to_le_bytes().as_ptr(), out_ptr, 8
);
if !req_ptr.is_null() && req_len > 0 {
std::ptr::copy_nonoverlapping(
req_ptr, out_ptr.add(8), req_len
);
}
*out_len_ptr = resp_len;
}
0
}
Note: Rust's standard allocator provides malloc/free automatically when targeting WASI with cdylib crate type.
WASI is a minimal system interface. Many POSIX features are absent. The template provides stub headers in src/wasi-stubs/ that shadow missing system headers via include_directories(BEFORE SYSTEM ...).
std::mutex or std::lock_guard<setjmp.h> (common in crypto libraries for SIMD detection)sigset_t, sigprocmask, SIG_SETMASK (common in CPU feature detection code)throw (directly or via template instantiation)If a dependency needs another missing header, create a stub in src/wasi-stubs/. The include_directories(BEFORE SYSTEM ...) in CMakeLists.txt ensures stubs shadow system headers. Use #include_next if you need to wrap the real WASI header and add extensions (see signal.h for an example).
The simplest working plugin. Echoes requests back with a timestamp prefix. See examples/echo_plugin.c.
To build it, edit CMakeLists.txt:
set(PLUGIN_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/examples/echo_plugin.c
)
A production plugin implementing P-256 ECDH key exchange for the OrbPro protection runtime. Uses Crypto++ via FetchContent. See sdn-license-plugin.
sdn-plugin-template/
├── CMakeLists.txt # Build config (edit for your plugin)
├── cmake/wasi-sdk.cmake # wasi-sdk toolchain
├── src/
│ ├── plugin.c # Skeleton (replace with your code)
│ ├── host_imports.h # Host function declarations
│ └── wasi-stubs/ # POSIX stubs for WASI
│ ├── cxa_stubs.cpp # C++ exception ABI
│ ├── mutex # std::mutex no-op
│ ├── setjmp.h # setjmp/longjmp no-op
│ └── signal.h # sigset_t/sigprocmask stubs
├── examples/
│ └── echo_plugin.c # Minimal working example
└── docs/
└── index.html # This documentation
# Configure
mkdir -p build-wasi && cd build-wasi
cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/wasi-sdk.cmake -DCMAKE_BUILD_TYPE=Release
# Build
make -j$(nproc)
# Clean rebuild
rm -rf build-wasi && mkdir build-wasi && cd build-wasi
cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/wasi-sdk.cmake -DCMAKE_BUILD_TYPE=Release && make
# Inspect exports
wasm-objdump -x my-sdn-plugin.wasm | grep "->"
| Variable | Description |
|---|---|
WASI_SDK_PREFIX | Path to wasi-sdk (build time) |
ORBPRO_KEY_BROKER_WASM_PATH | Path to plugin .wasm file (runtime) |