SDN Plugin SDK

Build WASI plugins for the Space Data Network server

Overview

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.

Language Agnostic

Write plugins in C, C++, Rust, Zig, or any language that compiles to WASI.

Sandboxed Execution

Plugins run inside the WASM sandbox. No filesystem, no network, no system calls.

Host Services

Plugins call back into the host for time, cryptographic randomness, and logging.

Portable Binaries

One .wasm file runs on any OS and architecture the Go server supports.

Quick Start

1. Clone the Template

git clone https://github.com/DigitalArsenal/sdn-plugin-template.git my-plugin
cd my-plugin

2. Install wasi-sdk

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

3. Build

mkdir -p build-wasi && cd build-wasi
cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/wasi-sdk.cmake -DCMAKE_BUILD_TYPE=Release
make -j$(nproc)

4. Verify

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"

Architecture

┌──────────────────────────────────────────────────┐ │ SDN Go Server │ │ │ │ ┌──────────┐ ┌────────────────────────────┐ │ │ │ HTTP │───▶│ Plugin Manager │ │ │ │ Router │ │ │ │ │ └──────────┘ │ ┌──────────────────────┐ │ │ │ │ │ Wazero Runtime │ │ │ │ │ │ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │ │ │ │ Your Plugin │ │ │ │ │ │ │ │ (.wasm) │ │ │ │ │ │ │ └───────┬────────┘ │ │ │ │ │ │ │ │ │ │ │ │ │ Host Imports: │ │ │ │ │ │ • clock_now_ms │ │ │ │ │ │ • random_bytes │ │ │ │ │ │ • log │ │ │ │ │ └──────────────────────┘ │ │ │ └────────────────────────────┘ │ └──────────────────────────────────────────────────┘

The lifecycle of a plugin:

  1. Go server reads the .wasm file and instantiates a Wazero runtime
  2. Host functions (sdn.clock_now_ms, sdn.random_bytes, sdn.log) are registered
  3. plugin_init is called with a binary config blob
  4. HTTP routes are mounted; incoming requests are dispatched to plugin_handle_request
  5. On shutdown, the Wazero runtime is closed

Plugin ABI

Every SDN WASI plugin must export these C functions. Use __attribute__((export_name("..."))) in C/C++ to ensure the symbols are exported.

Required Exports

ExportSignatureDescription
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).

Export Attributes (C/C++)

__attribute__((export_name("plugin_init")))
int32_t plugin_init(const uint8_t* config_ptr, size_t config_len) {
    // Parse config, initialize state
    return 0;
}

Config Format

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.

Status Codes

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.

Host Imports

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.

ImportSignatureDescription
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

Declaration (C/C++)

#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);

Declaration (Rust)

#[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);
}

Memory Model

WASM plugins have their own linear memory. The host and plugin share data through this memory using malloc/free.

How host → plugin calls work

  1. Host calls your malloc(size) to allocate space in plugin memory
  2. Host writes input data into that allocation
  3. Host calls your exported function with the pointer and length
  4. Your function reads the input and writes output to a pre-allocated buffer
  5. Host reads the output, then calls free() on all allocations

plugin_handle_request specifics

The host pre-allocates several buffers before calling plugin_handle_request:

// 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

Go Integration

On the server side, each WASI plugin needs a thin Go wrapper that implements the plugins.Plugin interface.

Plugin Interface

type Plugin interface {
    ID() string
    Start(ctx context.Context, runtime RuntimeContext) error
    RegisterRoutes(mux *http.ServeMux)
    Close() error
}

Minimal Go Wrapper

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
}

Registration (node.go)

if wasmPath := findMyPluginWasm(); wasmPath != "" {
    p := myplugin.New(wasmPath)
    if err := n.plugins.Register(p); err != nil {
        log.Warnf("plugin %q: %v", myplugin.ID, err)
    }
}

Plugin UI

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.

How It Works

  1. Your Go wrapper implements the UIProvider interface
  2. The plugin manager exposes a manifest at GET /api/v1/plugins/manifest
  3. The web client fetches the manifest and renders plugin cards
  4. Clicking a card opens your plugin's UI URL in an iframe
┌─────────────────────────────────────────────┐ │ SDN Web Client │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ Plugin A │ │ Plugin B │ │ Plugin C │ │ │ │ (card) │ │ (card) │ │ (card) │ │ │ └────┬─────┘ └──────────┘ └──────────┘ │ │ │ click │ │ ▼ │ │ ┌──────────────────────────────────────┐ │ │ │ iframe: /my-plugin/v1/ui │ │ │ │ ┌──────────────────────────────┐ │ │ │ │ │ Your plugin's HTML page │ │ │ │ │ └──────────────────────────────┘ │ │ │ └──────────────────────────────────────┘ │ └─────────────────────────────────────────────┘

UIProvider Interface

Implement 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
}

Example Implementation

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>`)
}

Manifest API

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"
    }
  }
]

Optional Interfaces

Beyond UIProvider, the plugin manager also checks for these optional interfaces via type assertion:

InterfaceMethodDescription
UIProviderUIDescriptor() UIDescriptorProvides web UI metadata for the Plugins page
VersionVersion() stringSemantic version shown on plugin card
DescriptionDescription() stringShort description shown on plugin card

UI Design Tips

C++ Plugins

C++ plugins work but require extra build configuration because WASI lacks threading, full POSIX signals, and C++ exception support.

CMakeLists.txt Changes

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 CXA Stubs

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
)

Link Signal Emulation

Uncomment in the linker flags:

target_link_options(sdn_plugin PRIVATE -lwasi-emulated-signal)

FetchContent Dependencies

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)

WASI Stubs Explained

The src/wasi-stubs/ directory provides headers that shadow missing POSIX features:

StubShadowsWhy
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.cppC++ exception ABIProvides __cxa_throw etc. as abort() — exceptions become WASM traps

Rust Plugins

Rust has first-class WASI support. Target wasm32-wasip1:

Cargo.toml

[package]
name = "my-sdn-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

Build

rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
# Output: target/wasm32-wasip1/release/my_sdn_plugin.wasm

Implementation

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 Stubs Reference

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 ...).

When You Need Stubs

When You Don't Need Stubs

Adding Your Own Stubs

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).

Examples

Echo Plugin (C)

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
)

OrbPro Key Broker (C++)

A production plugin implementing P-256 ECDH key exchange for the OrbPro protection runtime. Uses Crypto++ via FetchContent. See sdn-license-plugin.

Quick Reference

Project Structure

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

Build Commands

# 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 "->"

Environment Variables (Go host)

VariableDescription
WASI_SDK_PREFIXPath to wasi-sdk (build time)
ORBPRO_KEY_BROKER_WASM_PATHPath to plugin .wasm file (runtime)

Links