diff --git a/.gitignore b/.gitignore index 5bba50f..73e7116 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ Overlord-Server/plugins/*.zip Overlord-Server/plugins/.plugin-state.json plugin-sample-go/*.wasm plugin-sample-go/*.zip +plugin-sample-go/*.h # Runtime data Overlord-Server/data/ diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index dcba732..6aa5a8b 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -486,7 +486,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing payload"}) } manifestRaw, _ := payload["manifest"].(map[string]interface{}) - wasmBytes, _ := payload["wasm"].([]byte) + binaryBytes, _ := payload["binary"].([]byte) if env.Plugins == nil { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "plugin manager not ready"}) } @@ -494,7 +494,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in if err != nil { return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) } - if err := env.Plugins.Load(ctx, manifest, wasmBytes); err != nil { + if err := env.Plugins.Load(ctx, manifest, binaryBytes); err != nil { _ = wire.WriteMsg(ctx, env.Conn, wire.PluginEvent{Type: "plugin_event", PluginID: manifest.ID, Event: "error", Error: err.Error()}) return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) } diff --git a/Overlord-Client/cmd/agent/plugins/loader_darwin.go b/Overlord-Client/cmd/agent/plugins/loader_darwin.go new file mode 100644 index 0000000..48c4e7c --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_darwin.go @@ -0,0 +1,207 @@ +//go:build darwin && cgo + +package plugins + +/* +#include +#include +#include + +static void* so_dlopen(const char* path, char** errOut) { + void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL); + if (!h && errOut) *errOut = (char*)dlerror(); + return h; +} + +static void* so_dlsym(void* h, const char* name, char** errOut) { + void* s = dlsym(h, name); + if (!s && errOut) *errOut = (char*)dlerror(); + return s; +} + +static int so_dlclose(void* h) { + if (h) return dlclose(h); + return 0; +} + +// Go callback bridge — forward-declared here, implemented via //export below. +extern void goPluginHostCallback( + uintptr_t ctx, + const char* event, int eventLen, + const char* payload, int payloadLen); + +// Call the plugin's PluginOnLoad, injecting our callback bridge + context. +static int so_call_onload(void* fn, const char* info, int infoLen, uintptr_t ctx) { + typedef int (*fn_t)(const char*, int, uintptr_t, uintptr_t); + return ((fn_t)fn)(info, infoLen, (uintptr_t)goPluginHostCallback, ctx); +} + +static int so_call_onevent(void* fn, const char* ev, int evLen, const char* pl, int plLen) { + typedef int (*fn_t)(const char*, int, const char*, int); + return ((fn_t)fn)(ev, evLen, pl, plLen); +} + +static void so_call_onunload(void* fn) { + typedef void (*fn_t)(void); + ((fn_t)fn)(); +} +*/ +import "C" + +import ( + "errors" + "fmt" + "os" + "runtime/cgo" + "sync" + "unsafe" +) + +//export goPluginHostCallback +func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) { + h := cgo.Handle(ctx) + send := h.Value().(func(string, []byte)) + ev := C.GoStringN(event, eventLen) + var pl []byte + if payloadLen > 0 { + pl = C.GoBytes(unsafe.Pointer(payload), payloadLen) + } + send(ev, pl) +} + +func loadNativePlugin(data []byte) (NativePlugin, error) { + if len(data) == 0 { + return nil, errors.New("empty plugin binary") + } + + f, err := os.CreateTemp("", "*.dylib") + if err != nil { + return nil, fmt.Errorf("create temp: %w", err) + } + tmpPath := f.Name() + + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmpPath) + return nil, fmt.Errorf("write temp: %w", err) + } + f.Close() + + cPath := C.CString(tmpPath) + defer C.free(unsafe.Pointer(cPath)) + var cErr *C.char + handle := C.so_dlopen(cPath, &cErr) + + os.Remove(tmpPath) + + if handle == nil { + return nil, fmt.Errorf("dlopen: %s", C.GoString(cErr)) + } + + resolve := func(name string) (unsafe.Pointer, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + var cErr *C.char + s := C.so_dlsym(handle, cName, &cErr) + if s == nil { + return nil, fmt.Errorf("dlsym(%s): %s", name, C.GoString(cErr)) + } + return s, nil + } + + onLoad, err := resolve("PluginOnLoad") + if err != nil { + C.so_dlclose(handle) + return nil, err + } + onEvent, err := resolve("PluginOnEvent") + if err != nil { + C.so_dlclose(handle) + return nil, err + } + onUnload, err := resolve("PluginOnUnload") + if err != nil { + C.so_dlclose(handle) + return nil, err + } + + return &soPlugin{ + handle: handle, + onLoadFn: onLoad, + onEventFn: onEvent, + onUnloadFn: onUnload, + }, nil +} + +type soPlugin struct { + handle unsafe.Pointer // dlopen handle + onLoadFn unsafe.Pointer + onEventFn unsafe.Pointer + onUnloadFn unsafe.Pointer + cbHandle cgo.Handle + mu sync.Mutex +} + +func (p *soPlugin) Load(send func(string, []byte), hostInfo []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.cbHandle = cgo.NewHandle(send) + + var infoPtr *C.char + if len(hostInfo) > 0 { + infoPtr = (*C.char)(unsafe.Pointer(&hostInfo[0])) + } + + ret := C.so_call_onload(p.onLoadFn, infoPtr, C.int(len(hostInfo)), C.uintptr_t(p.cbHandle)) + if ret != 0 { + p.cbHandle.Delete() + p.cbHandle = 0 + return errors.New("PluginOnLoad returned error") + } + return nil +} + +func (p *soPlugin) Event(event string, payload []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + evBytes := []byte(event) + var evPtr, plPtr *C.char + if len(evBytes) > 0 { + evPtr = (*C.char)(unsafe.Pointer(&evBytes[0])) + } + if len(payload) > 0 { + plPtr = (*C.char)(unsafe.Pointer(&payload[0])) + } + + ret := C.so_call_onevent(p.onEventFn, evPtr, C.int(len(evBytes)), plPtr, C.int(len(payload))) + if ret != 0 { + return errors.New("PluginOnEvent returned error") + } + return nil +} + +func (p *soPlugin) Unload() { + p.mu.Lock() + defer p.mu.Unlock() + if p.onUnloadFn != nil { + C.so_call_onunload(p.onUnloadFn) + p.onUnloadFn = nil + } +} + +func (p *soPlugin) Close() error { + p.Unload() + p.mu.Lock() + defer p.mu.Unlock() + if p.cbHandle != 0 { + p.cbHandle.Delete() + p.cbHandle = 0 + } + if p.handle != nil { + C.so_dlclose(p.handle) + p.handle = nil + } + return nil +} diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux.go b/Overlord-Client/cmd/agent/plugins/loader_linux.go new file mode 100644 index 0000000..58c2bcf --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_linux.go @@ -0,0 +1,227 @@ +//go:build linux && cgo + +package plugins + +/* +#cgo LDFLAGS: -ldl +#include +#include +#include +#include +#include +#include + +#ifndef MFD_CLOEXEC +#define MFD_CLOEXEC 0x0001U +#endif + +// memfd_create wrapper — creates an anonymous in-memory file descriptor. +static int so_memfd_create(void) { + return (int)syscall(SYS_memfd_create, "plugin", MFD_CLOEXEC); +} + +// Write entire buffer to fd. Returns 0 on success, -1 on error. +static int so_write_all(int fd, const void* buf, size_t len) { + const char* p = (const char*)buf; + while (len > 0) { + ssize_t n = write(fd, p, len); + if (n <= 0) return -1; + p += n; + len -= (size_t)n; + } + return 0; +} + +// Load a shared library directly from an in-memory file descriptor. +static void* so_open_fd(int fd, char** errOut) { + char path[64]; + snprintf(path, sizeof(path), "/proc/self/fd/%d", fd); + void* h = dlopen(path, RTLD_NOW | RTLD_LOCAL); + if (!h && errOut) *errOut = (char*)dlerror(); + return h; +} + +static void* so_dlsym(void* h, const char* name, char** errOut) { + void* s = dlsym(h, name); + if (!s && errOut) *errOut = (char*)dlerror(); + return s; +} + +static int so_dlclose(void* h) { + if (h) return dlclose(h); + return 0; +} + +// Go callback bridge — forward-declared here, implemented via //export below. +extern void goPluginHostCallback( + uintptr_t ctx, + const char* event, int eventLen, + const char* payload, int payloadLen); + +// Call the plugin's PluginOnLoad, injecting our callback bridge + context. +static int so_call_onload(void* fn, const char* info, int infoLen, uintptr_t ctx) { + typedef int (*fn_t)(const char*, int, uintptr_t, uintptr_t); + return ((fn_t)fn)(info, infoLen, (uintptr_t)goPluginHostCallback, ctx); +} + +static int so_call_onevent(void* fn, const char* ev, int evLen, const char* pl, int plLen) { + typedef int (*fn_t)(const char*, int, const char*, int); + return ((fn_t)fn)(ev, evLen, pl, plLen); +} + +static void so_call_onunload(void* fn) { + typedef void (*fn_t)(void); + ((fn_t)fn)(); +} +*/ +import "C" + +import ( + "errors" + "fmt" + "runtime/cgo" + "sync" + "unsafe" +) + +//export goPluginHostCallback +func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) { + h := cgo.Handle(ctx) + send := h.Value().(func(string, []byte)) + ev := C.GoStringN(event, eventLen) + var pl []byte + if payloadLen > 0 { + pl = C.GoBytes(unsafe.Pointer(payload), payloadLen) + } + send(ev, pl) +} + +func loadNativePlugin(data []byte) (NativePlugin, error) { + if len(data) == 0 { + return nil, errors.New("empty plugin binary") + } + + fd := C.so_memfd_create() + if fd < 0 { + return nil, errors.New("memfd_create failed") + } + + if C.so_write_all(fd, unsafe.Pointer(&data[0]), C.size_t(len(data))) != 0 { + C.close(fd) + return nil, errors.New("write to memfd failed") + } + + var cErr *C.char + handle := C.so_open_fd(fd, &cErr) + C.close(fd) + if handle == nil { + return nil, fmt.Errorf("dlopen: %s", C.GoString(cErr)) + } + + resolve := func(name string) (unsafe.Pointer, error) { + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + var cErr *C.char + s := C.so_dlsym(handle, cName, &cErr) + if s == nil { + return nil, fmt.Errorf("dlsym(%s): %s", name, C.GoString(cErr)) + } + return s, nil + } + + onLoad, err := resolve("PluginOnLoad") + if err != nil { + C.so_dlclose(handle) + return nil, err + } + onEvent, err := resolve("PluginOnEvent") + if err != nil { + C.so_dlclose(handle) + return nil, err + } + onUnload, err := resolve("PluginOnUnload") + if err != nil { + C.so_dlclose(handle) + return nil, err + } + + return &soPlugin{ + handle: handle, + onLoadFn: onLoad, + onEventFn: onEvent, + onUnloadFn: onUnload, + }, nil +} + +type soPlugin struct { + handle unsafe.Pointer + onLoadFn unsafe.Pointer + onEventFn unsafe.Pointer + onUnloadFn unsafe.Pointer + cbHandle cgo.Handle + mu sync.Mutex +} + +func (p *soPlugin) Load(send func(string, []byte), hostInfo []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.cbHandle = cgo.NewHandle(send) + + var infoPtr *C.char + if len(hostInfo) > 0 { + infoPtr = (*C.char)(unsafe.Pointer(&hostInfo[0])) + } + + ret := C.so_call_onload(p.onLoadFn, infoPtr, C.int(len(hostInfo)), C.uintptr_t(p.cbHandle)) + if ret != 0 { + p.cbHandle.Delete() + p.cbHandle = 0 + return errors.New("PluginOnLoad returned error") + } + return nil +} + +func (p *soPlugin) Event(event string, payload []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + evBytes := []byte(event) + var evPtr, plPtr *C.char + if len(evBytes) > 0 { + evPtr = (*C.char)(unsafe.Pointer(&evBytes[0])) + } + if len(payload) > 0 { + plPtr = (*C.char)(unsafe.Pointer(&payload[0])) + } + + ret := C.so_call_onevent(p.onEventFn, evPtr, C.int(len(evBytes)), plPtr, C.int(len(payload))) + if ret != 0 { + return errors.New("PluginOnEvent returned error") + } + return nil +} + +func (p *soPlugin) Unload() { + p.mu.Lock() + defer p.mu.Unlock() + if p.onUnloadFn != nil { + C.so_call_onunload(p.onUnloadFn) + p.onUnloadFn = nil + } +} + +func (p *soPlugin) Close() error { + p.Unload() + p.mu.Lock() + defer p.mu.Unlock() + if p.cbHandle != 0 { + p.cbHandle.Delete() + p.cbHandle = 0 + } + if p.handle != nil { + C.so_dlclose(p.handle) + p.handle = nil + } + return nil +} diff --git a/Overlord-Client/cmd/agent/plugins/loader_stub.go b/Overlord-Client/cmd/agent/plugins/loader_stub.go new file mode 100644 index 0000000..398c4fc --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_stub.go @@ -0,0 +1,9 @@ +//go:build !(linux && cgo) && !(darwin && cgo) && !windows + +package plugins + +import "errors" + +func loadNativePlugin(data []byte) (NativePlugin, error) { + return nil, errors.New("native plugins not supported on this platform (requires cgo on linux/darwin, or windows)") +} diff --git a/Overlord-Client/cmd/agent/plugins/loader_windows.go b/Overlord-Client/cmd/agent/plugins/loader_windows.go new file mode 100644 index 0000000..195c31c --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/loader_windows.go @@ -0,0 +1,138 @@ +//go:build windows + +package plugins + +import ( + "errors" + "fmt" + "sync" + "syscall" + "unsafe" +) + +func loadNativePlugin(data []byte) (NativePlugin, error) { + if len(data) == 0 { + return nil, errors.New("empty plugin binary") + } + + mm, err := LoadMemoryModule(data) + if err != nil { + return nil, fmt.Errorf("pe load: %w", err) + } + + if err := mm.CallEntryPoint(dllProcessAttach); err != nil { + mm.Free() + return nil, fmt.Errorf("DllMain init: %w", err) + } + + onLoad, err := mm.GetExport("PluginOnLoad") + if err != nil { + mm.Free() + return nil, err + } + onEvent, err := mm.GetExport("PluginOnEvent") + if err != nil { + mm.Free() + return nil, err + } + onUnload, err := mm.GetExport("PluginOnUnload") + if err != nil { + mm.Free() + return nil, err + } + + setCallback, _ := mm.GetExport("PluginSetCallback") + + dp := &dllPlugin{ + mem: mm, + onLoadAddr: onLoad, + onEventAddr: onEvent, + onUnloadAddr: onUnload, + setCallbackAddr: setCallback, + } + return dp, nil +} + +type dllPlugin struct { + mem *MemoryModule + onLoadAddr uintptr + onEventAddr uintptr + onUnloadAddr uintptr + setCallbackAddr uintptr + callbackHandle uintptr // prevent GC of the callback closure + mu sync.Mutex +} + +func (p *dllPlugin) Load(send func(string, []byte), hostInfo []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + // Create a stdcall callback the DLL can invoke to send events to the host. + cb := syscall.NewCallback(func(eventPtr, eventLen, payloadPtr, payloadLen uintptr) uintptr { + event := make([]byte, eventLen) + if eventLen > 0 { + copy(event, unsafe.Slice((*byte)(unsafe.Pointer(eventPtr)), eventLen)) + } + payload := make([]byte, payloadLen) + if payloadLen > 0 { + copy(payload, unsafe.Slice((*byte)(unsafe.Pointer(payloadPtr)), payloadLen)) + } + send(string(event), payload) + return 0 + }) + p.callbackHandle = cb + + if p.setCallbackAddr != 0 { + syscall.SyscallN(p.setCallbackAddr, cb) + } + + var infoPtr uintptr + infoLen := uintptr(len(hostInfo)) + if len(hostInfo) > 0 { + infoPtr = uintptr(unsafe.Pointer(&hostInfo[0])) + } + ret, _, _ := syscall.SyscallN(p.onLoadAddr, infoPtr, infoLen, cb) + if int32(ret) != 0 { + return errors.New("PluginOnLoad returned non-zero") + } + return nil +} + +func (p *dllPlugin) Event(event string, payload []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + eventBytes := []byte(event) + var eventPtr, payloadPtr uintptr + eventLen := uintptr(len(eventBytes)) + payloadLen := uintptr(len(payload)) + if len(eventBytes) > 0 { + eventPtr = uintptr(unsafe.Pointer(&eventBytes[0])) + } + if len(payload) > 0 { + payloadPtr = uintptr(unsafe.Pointer(&payload[0])) + } + + ret, _, _ := syscall.SyscallN(p.onEventAddr, eventPtr, eventLen, payloadPtr, payloadLen) + if int32(ret) != 0 { + return errors.New("PluginOnEvent returned non-zero") + } + return nil +} + +func (p *dllPlugin) Unload() { + p.mu.Lock() + defer p.mu.Unlock() + if p.onUnloadAddr != 0 { + syscall.SyscallN(p.onUnloadAddr) + } +} + +func (p *dllPlugin) Close() error { + p.Unload() + if p.mem != nil { + p.mem.Free() + p.mem = nil + } + return nil +} diff --git a/Overlord-Client/cmd/agent/plugins/manager.go b/Overlord-Client/cmd/agent/plugins/manager.go index f30ddbc..96ab8d8 100644 --- a/Overlord-Client/cmd/agent/plugins/manager.go +++ b/Overlord-Client/cmd/agent/plugins/manager.go @@ -2,16 +2,11 @@ package plugins import ( "context" + "encoding/json" "errors" - "io" "log" "sync" - "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" - "github.com/vmihailenco/msgpack/v5" - "overlord-client/cmd/agent/wire" ) @@ -26,16 +21,7 @@ type Manager struct { type pluginInstance struct { id string manifest PluginManifest - ctx context.Context - cancel context.CancelFunc - runtime wazero.Runtime - module api.Module - stdin io.WriteCloser - stdout io.ReadCloser - stderr io.ReadCloser - enc *msgpack.Encoder - dec *msgpack.Decoder - encMu sync.Mutex + native NativePlugin } type pendingBundle struct { @@ -135,9 +121,9 @@ func (m *Manager) FinalizeBundle(ctx context.Context, pluginId string) error { return m.Load(ctx, manifest, combined) } -func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte) error { - if len(wasm) == 0 { - return errors.New("empty wasm payload") +func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []byte) error { + if len(binary) == 0 { + return errors.New("empty plugin binary") } pluginID := manifest.ID if pluginID == "" { @@ -146,71 +132,51 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte m.mu.Lock() if existing, ok := m.plugins[pluginID]; ok { - _ = existing.Close() + existing.native.Close() delete(m.plugins, pluginID) } m.mu.Unlock() - runtimeCtx, cancel := context.WithCancel(ctx) - rt := wazero.NewRuntime(runtimeCtx) - if _, err := wasi_snapshot_preview1.Instantiate(runtimeCtx, rt); err != nil { - cancel() - return err - } - - stdinR, stdinW := io.Pipe() - stdoutR, stdoutW := io.Pipe() - stderrR, stderrW := io.Pipe() - - config := wazero.NewModuleConfig(). - WithStdin(stdinR). - WithStdout(stdoutW). - WithStderr(stderrW). - WithStartFunctions() - - compiled, err := rt.CompileModule(runtimeCtx, wasm) + np, err := loadNativePlugin(binary) if err != nil { - cancel() - return err - } - - module, err := rt.InstantiateModule(runtimeCtx, compiled, config) - if err != nil { - cancel() return err } pi := &pluginInstance{ id: pluginID, manifest: manifest, - ctx: runtimeCtx, - cancel: cancel, - runtime: rt, - module: module, - stdin: stdinW, - stdout: stdoutR, - stderr: stderrR, - enc: msgpack.NewEncoder(stdinW), - dec: msgpack.NewDecoder(stdoutR), + native: np, } - goSafe("plugin readLoop", pi.cancel, func() { - m.readLoop(pi) - }) - goSafe("plugin readStderr", pi.cancel, func() { - m.readStderr(pi) - }) - - if start := module.ExportedFunction("_start"); start != nil { - goSafe("plugin _start", pi.cancel, func() { - if _, err := start.Call(runtimeCtx); err != nil { - log.Printf("[plugin] %s _start error: %v", pluginID, err) + send := func(event string, payload []byte) { + var payloadVal interface{} + if len(payload) > 0 { + var parsed interface{} + if json.Unmarshal(payload, &parsed) == nil { + payloadVal = parsed + } else { + payloadVal = string(payload) } + } + err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{ + Type: "plugin_event", + PluginID: pluginID, + Event: event, + Payload: payloadVal, }) + if err != nil { + log.Printf("[plugin] %s send event error: %v", pluginID, err) + } } - if err := m.sendInit(pi); err != nil { - _ = pi.Close() + hostJSON, err := json.Marshal(m.host) + if err != nil { + np.Close() + return err + } + + if err := np.Load(send, hostJSON); err != nil { + np.Close() return err } @@ -218,7 +184,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, wasm []byte m.plugins[pluginID] = pi m.mu.Unlock() - log.Printf("[plugin] loaded %s", pluginID) + log.Printf("[plugin] loaded %s (native)", pluginID) return nil } @@ -229,15 +195,19 @@ func (m *Manager) Dispatch(ctx context.Context, pluginId, event string, payload if pi == nil { return nil } - msg := PluginMessage{Type: "event", Event: event, Payload: payload} - return m.sendMessage(ctx, pi, msg) + + data, err := json.Marshal(payload) + if err != nil { + return err + } + return pi.native.Event(event, data) } func (m *Manager) Close() { m.mu.Lock() defer m.mu.Unlock() for id, pi := range m.plugins { - _ = pi.Close() + pi.native.Close() delete(m.plugins, id) } } @@ -246,103 +216,7 @@ func (m *Manager) Unload(pluginId string) { m.mu.Lock() defer m.mu.Unlock() if pi, ok := m.plugins[pluginId]; ok { - _ = pi.Close() + pi.native.Close() delete(m.plugins, pluginId) } } - -func (m *Manager) sendInit(pi *pluginInstance) error { - payload := map[string]interface{}{ - "manifest": pi.manifest, - "host": m.host, - } - msg := PluginMessage{Type: "init", Payload: payload} - return m.sendMessage(context.Background(), pi, msg) -} - -func (m *Manager) sendMessage(ctx context.Context, pi *pluginInstance, msg PluginMessage) error { - pi.encMu.Lock() - defer pi.encMu.Unlock() - select { - case <-ctx.Done(): - return ctx.Err() - default: - return pi.enc.Encode(msg) - } -} - -func (m *Manager) readLoop(pi *pluginInstance) { - defer func() { - if r := recover(); r != nil { - log.Printf("[plugin] %s read loop panic: %v", pi.id, r) - } - }() - for { - var msg map[string]interface{} - if err := pi.dec.Decode(&msg); err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { - return - } - log.Printf("[plugin] %s read error: %v", pi.id, err) - return - } - m.handlePluginMessage(pi.id, msg) - } -} - -func (m *Manager) readStderr(pi *pluginInstance) { - defer func() { - if r := recover(); r != nil { - log.Printf("[plugin] %s stderr loop panic: %v", pi.id, r) - } - }() - buf := make([]byte, 4096) - for { - n, err := pi.stderr.Read(buf) - if n > 0 { - log.Printf("[plugin] %s stderr: %s", pi.id, string(buf[:n])) - } - if err != nil { - return - } - } -} - -func (m *Manager) handlePluginMessage(pluginId string, msg map[string]interface{}) { - msgType, _ := msg["type"].(string) - if msgType == "log" { - log.Printf("[plugin] %s log: %v", pluginId, msg["payload"]) - return - } - if msgType == "event" { - event, _ := msg["event"].(string) - payload := msg["payload"] - if event == "" { - return - } - err := wire.WriteMsg(context.Background(), m.writer, wire.PluginEvent{ - Type: "plugin_event", - PluginID: pluginId, - Event: event, - Payload: payload, - }) - if err != nil { - log.Printf("[plugin] %s send event error: %v", pluginId, err) - } - return - } -} - -func (pi *pluginInstance) Close() error { - pi.cancel() - _ = pi.stdin.Close() - _ = pi.stdout.Close() - _ = pi.stderr.Close() - if pi.module != nil { - _ = pi.module.Close(context.Background()) - } - if pi.runtime != nil { - _ = pi.runtime.Close(context.Background()) - } - return nil -} diff --git a/Overlord-Client/cmd/agent/plugins/peloader_windows.go b/Overlord-Client/cmd/agent/plugins/peloader_windows.go new file mode 100644 index 0000000..dc02db7 --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/peloader_windows.go @@ -0,0 +1,524 @@ +//go:build windows + +package plugins + +import ( + "errors" + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + imageDOSSignature = 0x5A4D // "MZ" + imageNTSignature = 0x00004550 // "PE\0\0" + + imageOptionalHdrMagicPE32Plus = 0x20B + + imageDirectoryEntryExport = 0 + imageDirectoryEntryImport = 1 + imageDirectoryEntryBaseReloc = 5 + imageDirectoryEntryTLS = 9 + + imageRelBasedDir64 = 10 + imageRelBasedHighLow = 3 + imageRelBasedAbsolute = 0 + + imageSCNMemExecute = 0x20000000 + imageSCNMemRead = 0x40000000 + imageSCNMemWrite = 0x80000000 + + dllProcessAttach = 1 + dllProcessDetach = 0 +) + +type imageDOSHeader struct { + Magic uint16 + _ [28]uint16 + LfaNew int32 +} + +type imageFileHeader struct { + Machine uint16 + NumberOfSections uint16 + TimeDateStamp uint32 + PointerToSymbolTable uint32 + NumberOfSymbols uint32 + SizeOfOptionalHeader uint16 + Characteristics uint16 +} + +type imageDataDirectory struct { + VirtualAddress uint32 + Size uint32 +} + +type imageOptionalHeader64 struct { + Magic uint16 + _ [14]byte + AddressOfEntryPoint uint32 + _ [4]byte + ImageBase uint64 + SectionAlignment uint32 + FileAlignment uint32 + _ [16]byte + SizeOfImage uint32 + SizeOfHeaders uint32 + _ [4]byte + _ [4]byte // Subsystem + DllCharacteristics + _ [36]byte + NumberOfRvaAndSizes uint32 + DataDirectory [16]imageDataDirectory +} + +type imageNTHeaders64 struct { + Signature uint32 + FileHeader imageFileHeader + OptionalHeader imageOptionalHeader64 +} + +type imageSectionHeader struct { + Name [8]byte + VirtualSize uint32 + VirtualAddress uint32 + SizeOfRawData uint32 + PointerToRawData uint32 + _ [12]byte + Characteristics uint32 +} + +type imageImportDescriptor struct { + OriginalFirstThunk uint32 + TimeDateStamp uint32 + ForwarderChain uint32 + Name uint32 + FirstThunk uint32 +} + +type imageBaseRelocation struct { + VirtualAddress uint32 + SizeOfBlock uint32 +} + +type imageExportDirectory struct { + _ [12]byte + Name uint32 + Base uint32 + NumberOfFunctions uint32 + NumberOfNames uint32 + AddressOfFunctions uint32 + AddressOfNames uint32 + AddressOfNameOrdinals uint32 +} + +type imageTLSDirectory64 struct { + StartAddressOfRawData uint64 + EndAddressOfRawData uint64 + AddressOfIndex uint64 + AddressOfCallBacks uint64 + SizeOfZeroFill uint32 + Characteristics uint32 +} + +type MemoryModule struct { + base uintptr + size uintptr + entryPoint uintptr + exports map[string]uintptr + importDLLs []windows.Handle + initialized bool + tlsIndex uint32 + tlsData uintptr +} + +func LoadMemoryModule(data []byte) (*MemoryModule, error) { + if len(data) < int(unsafe.Sizeof(imageDOSHeader{})) { + return nil, errors.New("pe: data too small for DOS header") + } + + dosHdr := (*imageDOSHeader)(unsafe.Pointer(&data[0])) + if dosHdr.Magic != imageDOSSignature { + return nil, errors.New("pe: invalid DOS signature") + } + + ntOffset := int(dosHdr.LfaNew) + if ntOffset < 0 || ntOffset+int(unsafe.Sizeof(imageNTHeaders64{})) > len(data) { + return nil, errors.New("pe: NT headers out of bounds") + } + ntHdr := (*imageNTHeaders64)(unsafe.Pointer(&data[ntOffset])) + if ntHdr.Signature != imageNTSignature { + return nil, errors.New("pe: invalid NT signature") + } + if ntHdr.OptionalHeader.Magic != imageOptionalHdrMagicPE32Plus { + return nil, errors.New("pe: only PE32+ (64-bit) is supported") + } + + imageSize := uintptr(ntHdr.OptionalHeader.SizeOfImage) + preferredBase := uintptr(ntHdr.OptionalHeader.ImageBase) + + base, err := windows.VirtualAlloc(preferredBase, imageSize, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE) + if err != nil || base == 0 { + base, err = windows.VirtualAlloc(0, imageSize, windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE) + if err != nil { + return nil, fmt.Errorf("pe: VirtualAlloc failed: %w", err) + } + } + + mm := &MemoryModule{ + base: base, + size: imageSize, + exports: make(map[string]uintptr), + tlsIndex: 0xFFFFFFFF, + } + + headerSize := uintptr(ntHdr.OptionalHeader.SizeOfHeaders) + if headerSize > uintptr(len(data)) { + headerSize = uintptr(len(data)) + } + copyMem(base, &data[0], headerSize) + + sectionStart := ntOffset + int(unsafe.Sizeof(ntHdr.Signature)) + + int(unsafe.Sizeof(ntHdr.FileHeader)) + + int(ntHdr.FileHeader.SizeOfOptionalHeader) + + for i := 0; i < int(ntHdr.FileHeader.NumberOfSections); i++ { + off := sectionStart + i*int(unsafe.Sizeof(imageSectionHeader{})) + if off+int(unsafe.Sizeof(imageSectionHeader{})) > len(data) { + mm.Free() + return nil, errors.New("pe: section header out of bounds") + } + sec := (*imageSectionHeader)(unsafe.Pointer(&data[off])) + if sec.SizeOfRawData > 0 { + rawOff := int(sec.PointerToRawData) + rawEnd := rawOff + int(sec.SizeOfRawData) + if rawEnd > len(data) { + mm.Free() + return nil, errors.New("pe: section raw data out of bounds") + } + copySize := uintptr(sec.SizeOfRawData) + if sec.VirtualSize > 0 && uintptr(sec.VirtualSize) < copySize { + copySize = uintptr(sec.VirtualSize) + } + dest := uintptr(sec.VirtualAddress) + if dest+copySize > imageSize { + if dest >= imageSize { + continue + } + copySize = imageSize - dest + } + copyMem(base+dest, &data[rawOff], copySize) + } + } + + mappedNT := (*imageNTHeaders64)(unsafe.Pointer(base + uintptr(dosHdr.LfaNew))) + delta := int64(base) - int64(mappedNT.OptionalHeader.ImageBase) + + if delta != 0 { + relocDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryBaseReloc] + if relocDir.VirtualAddress != 0 && relocDir.Size != 0 { + if err := mm.processRelocations(relocDir, delta); err != nil { + mm.Free() + return nil, err + } + } + } + + importDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryImport] + if importDir.VirtualAddress != 0 && importDir.Size != 0 { + if err := mm.resolveImports(importDir); err != nil { + mm.Free() + return nil, err + } + } + + for i := 0; i < int(ntHdr.FileHeader.NumberOfSections); i++ { + off := sectionStart + i*int(unsafe.Sizeof(imageSectionHeader{})) + sec := (*imageSectionHeader)(unsafe.Pointer(&data[off])) + prot := sectionProtection(sec.Characteristics) + size := uintptr(sec.VirtualSize) + if size == 0 { + size = uintptr(sec.SizeOfRawData) + } + if size == 0 { + continue + } + var oldProt uint32 + _ = windows.VirtualProtect(base+uintptr(sec.VirtualAddress), size, prot, &oldProt) + } + + mm.setupTLS(dllProcessAttach) + + if mappedNT.OptionalHeader.AddressOfEntryPoint != 0 { + mm.entryPoint = base + uintptr(mappedNT.OptionalHeader.AddressOfEntryPoint) + } + + exportDir := mappedNT.OptionalHeader.DataDirectory[imageDirectoryEntryExport] + if exportDir.VirtualAddress != 0 && exportDir.Size != 0 { + mm.parseExports(exportDir) + } + + return mm, nil +} + +func (mm *MemoryModule) CallEntryPoint(reason uint32) error { + if mm.entryPoint == 0 { + return nil + } + ret, _, _ := syscall.SyscallN(mm.entryPoint, mm.base, uintptr(reason), 0) + if ret == 0 && reason == dllProcessAttach { + return errors.New("pe: DllMain returned FALSE") + } + mm.initialized = (reason == dllProcessAttach) + return nil +} + +func (mm *MemoryModule) GetExport(name string) (uintptr, error) { + addr, ok := mm.exports[name] + if !ok { + return 0, fmt.Errorf("pe: export %q not found", name) + } + return addr, nil +} + +func (mm *MemoryModule) Free() { + if mm.base == 0 { + return + } + if mm.initialized && mm.entryPoint != 0 { + syscall.SyscallN(mm.entryPoint, mm.base, dllProcessDetach, 0) + mm.initialized = false + } + if mm.tlsIndex != 0xFFFFFFFF { + if mm.tlsData != 0 { + _ = windows.VirtualFree(mm.tlsData, 0, windows.MEM_RELEASE) + } + procTlsFree.Call(uintptr(mm.tlsIndex)) + } + for _, h := range mm.importDLLs { + _ = windows.FreeLibrary(h) + } + mm.importDLLs = nil + _ = windows.VirtualFree(mm.base, 0, windows.MEM_RELEASE) + mm.base = 0 +} + +func (mm *MemoryModule) processRelocations(dir imageDataDirectory, delta int64) error { + offset := uintptr(dir.VirtualAddress) + end := offset + uintptr(dir.Size) + + for offset < end { + block := (*imageBaseRelocation)(unsafe.Pointer(mm.base + offset)) + if block.SizeOfBlock == 0 { + break + } + count := (block.SizeOfBlock - 8) / 2 + entries := mm.base + offset + 8 + + for i := uint32(0); i < count; i++ { + entry := *(*uint16)(unsafe.Pointer(entries + uintptr(i)*2)) + typ := entry >> 12 + off := uintptr(entry & 0xFFF) + addr := mm.base + uintptr(block.VirtualAddress) + off + + switch typ { + case imageRelBasedAbsolute: + case imageRelBasedHighLow: + val := (*uint32)(unsafe.Pointer(addr)) + *val = uint32(int64(*val) + delta) + case imageRelBasedDir64: + val := (*uint64)(unsafe.Pointer(addr)) + *val = uint64(int64(*val) + delta) + default: + return fmt.Errorf("pe: unsupported relocation type %d", typ) + } + } + offset += uintptr(block.SizeOfBlock) + } + return nil +} + +func (mm *MemoryModule) resolveImports(dir imageDataDirectory) error { + descSize := unsafe.Sizeof(imageImportDescriptor{}) + offset := uintptr(dir.VirtualAddress) + + for { + desc := (*imageImportDescriptor)(unsafe.Pointer(mm.base + offset)) + if desc.Name == 0 { + break + } + + dllName := peString(mm.base + uintptr(desc.Name)) + hDLL, err := windows.LoadLibrary(dllName) + if err != nil { + return fmt.Errorf("pe: LoadLibrary(%s): %w", dllName, err) + } + mm.importDLLs = append(mm.importDLLs, hDLL) + + thunkRef := mm.base + uintptr(desc.OriginalFirstThunk) + thunkAddr := mm.base + uintptr(desc.FirstThunk) + if desc.OriginalFirstThunk == 0 { + thunkRef = thunkAddr + } + + for { + ref := *(*uint64)(unsafe.Pointer(thunkRef)) + if ref == 0 { + break + } + + var procAddr uintptr + if ref&(1<<63) != 0 { + ordinal := uint16(ref & 0xFFFF) + procAddr, err = getProcByOrdinal(hDLL, ordinal) + } else { + nameAddr := mm.base + uintptr(ref) + 2 + funcName := peString(nameAddr) + procAddr, err = windows.GetProcAddress(hDLL, funcName) + } + if err != nil { + return fmt.Errorf("pe: import resolve from %s: %w", dllName, err) + } + + *(*uintptr)(unsafe.Pointer(thunkAddr)) = procAddr + thunkRef += 8 + thunkAddr += 8 + } + offset += descSize + } + return nil +} + +func (mm *MemoryModule) parseExports(dir imageDataDirectory) { + if dir.Size == 0 { + return + } + expDir := (*imageExportDirectory)(unsafe.Pointer(mm.base + uintptr(dir.VirtualAddress))) + numNames := int(expDir.NumberOfNames) + if numNames == 0 { + return + } + + namesRVA := mm.base + uintptr(expDir.AddressOfNames) + ordinalsRVA := mm.base + uintptr(expDir.AddressOfNameOrdinals) + funcsRVA := mm.base + uintptr(expDir.AddressOfFunctions) + + exportStart := uintptr(dir.VirtualAddress) + exportEnd := exportStart + uintptr(dir.Size) + + for i := 0; i < numNames; i++ { + nameRVA := *(*uint32)(unsafe.Pointer(namesRVA + uintptr(i)*4)) + ordinal := *(*uint16)(unsafe.Pointer(ordinalsRVA + uintptr(i)*2)) + funcRVA := *(*uint32)(unsafe.Pointer(funcsRVA + uintptr(ordinal)*4)) + + if uintptr(funcRVA) >= exportStart && uintptr(funcRVA) < exportEnd { + continue + } + + name := peString(mm.base + uintptr(nameRVA)) + mm.exports[name] = mm.base + uintptr(funcRVA) + } +} + +func (mm *MemoryModule) setupTLS(reason uint32) { + dosHdr := (*imageDOSHeader)(unsafe.Pointer(mm.base)) + ntHdr := (*imageNTHeaders64)(unsafe.Pointer(mm.base + uintptr(dosHdr.LfaNew))) + tlsDir := ntHdr.OptionalHeader.DataDirectory[imageDirectoryEntryTLS] + if tlsDir.VirtualAddress == 0 || tlsDir.Size == 0 { + return + } + + tls := (*imageTLSDirectory64)(unsafe.Pointer(mm.base + uintptr(tlsDir.VirtualAddress))) + + idx, _, _ := procTlsAlloc.Call() + if idx == 0xFFFFFFFF { + return + } + mm.tlsIndex = uint32(idx) + + if tls.AddressOfIndex != 0 { + *(*uint32)(unsafe.Pointer(uintptr(tls.AddressOfIndex))) = mm.tlsIndex + } + + dataSize := uintptr(tls.EndAddressOfRawData - tls.StartAddressOfRawData) + totalSize := dataSize + uintptr(tls.SizeOfZeroFill) + if totalSize > 0 { + tlsData, err := windows.VirtualAlloc(0, totalSize, + windows.MEM_RESERVE|windows.MEM_COMMIT, windows.PAGE_READWRITE) + if err == nil && tlsData != 0 { + if dataSize > 0 { + copyMem(tlsData, (*byte)(unsafe.Pointer(uintptr(tls.StartAddressOfRawData))), dataSize) + } + procTlsSetValue.Call(uintptr(mm.tlsIndex), tlsData) + mm.tlsData = tlsData + } + } + + if tls.AddressOfCallBacks != 0 { + cbAddr := uintptr(tls.AddressOfCallBacks) + for { + cb := *(*uintptr)(unsafe.Pointer(cbAddr)) + if cb == 0 { + break + } + syscall.SyscallN(cb, mm.base, uintptr(reason), 0) + cbAddr += 8 + } + } +} + +func peString(addr uintptr) string { + var buf []byte + for i := 0; i < 4096; i++ { // safety limit + b := *(*byte)(unsafe.Pointer(addr + uintptr(i))) + if b == 0 { + break + } + buf = append(buf, b) + } + return string(buf) +} + +var ( + modKernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetProcAddr = modKernel32.NewProc("GetProcAddress") + procTlsAlloc = modKernel32.NewProc("TlsAlloc") + procTlsSetValue = modKernel32.NewProc("TlsSetValue") + procTlsFree = modKernel32.NewProc("TlsFree") +) + +func getProcByOrdinal(module windows.Handle, ordinal uint16) (uintptr, error) { + r, _, err := procGetProcAddr.Call(uintptr(module), uintptr(ordinal)) + if r == 0 { + return 0, fmt.Errorf("GetProcAddress ordinal %d: %w", ordinal, err) + } + return r, nil +} + +func sectionProtection(chars uint32) uint32 { + exec := chars&imageSCNMemExecute != 0 + read := chars&imageSCNMemRead != 0 + write := chars&imageSCNMemWrite != 0 + + switch { + case exec && write: + return windows.PAGE_EXECUTE_READWRITE + case exec && read: + return windows.PAGE_EXECUTE_READ + case exec: + return windows.PAGE_EXECUTE + case write: + return windows.PAGE_READWRITE + case read: + return windows.PAGE_READONLY + default: + return windows.PAGE_NOACCESS + } +} + +func copyMem(dst uintptr, src *byte, size uintptr) { + dstSlice := unsafe.Slice((*byte)(unsafe.Pointer(dst)), size) + srcSlice := unsafe.Slice(src, size) + copy(dstSlice, srcSlice) +} diff --git a/Overlord-Client/cmd/agent/plugins/types.go b/Overlord-Client/cmd/agent/plugins/types.go index d4b63b0..cd8c9a6 100644 --- a/Overlord-Client/cmd/agent/plugins/types.go +++ b/Overlord-Client/cmd/agent/plugins/types.go @@ -9,13 +9,14 @@ type PluginAssets struct { } type PluginManifest struct { - ID string `msgpack:"id" json:"id"` - Name string `msgpack:"name" json:"name"` - Version string `msgpack:"version,omitempty" json:"version,omitempty"` - Description string `msgpack:"description,omitempty" json:"description,omitempty"` - Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"` - Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"` - Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"` + ID string `msgpack:"id" json:"id"` + Name string `msgpack:"name" json:"name"` + Version string `msgpack:"version,omitempty" json:"version,omitempty"` + Description string `msgpack:"description,omitempty" json:"description,omitempty"` + Binary string `msgpack:"binary,omitempty" json:"binary,omitempty"` + Binaries map[string]string `msgpack:"binaries,omitempty" json:"binaries,omitempty"` + Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"` + Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"` } type PluginMessage struct { @@ -26,10 +27,20 @@ type PluginMessage struct { } type HostInfo struct { - ClientID string `msgpack:"clientId"` - OS string `msgpack:"os"` - Arch string `msgpack:"arch"` - Version string `msgpack:"version"` + ClientID string `msgpack:"clientId" json:"clientId"` + OS string `msgpack:"os" json:"os"` + Arch string `msgpack:"arch" json:"arch"` + Version string `msgpack:"version" json:"version"` +} + +type NativePlugin interface { + Load(send func(event string, payload []byte), hostInfo []byte) error + + Event(event string, payload []byte) error + + Unload() + + Close() error } func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { @@ -41,6 +52,15 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { manifest.Binary = stringVal(m["binary"]) manifest.Entry = stringVal(m["entry"]) + if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok { + manifest.Binaries = make(map[string]string, len(binariesRaw)) + for k, v := range binariesRaw { + if s, ok := v.(string); ok { + manifest.Binaries[k] = s + } + } + } + if assetsRaw, ok := m["assets"].(map[string]interface{}); ok { manifest.Assets = PluginAssets{ HTML: stringVal(assetsRaw["html"]), diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod index 9dfbb0a..a92e81b 100644 --- a/Overlord-Client/go.mod +++ b/Overlord-Client/go.mod @@ -7,7 +7,6 @@ require ( github.com/gen2brain/x264-go v0.3.1 // optional: used when cgo is enabled github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 github.com/pixiv/go-libjpeg v0.0.0-20190822045933-3da21a74767d // optional: turbojpeg build tag - github.com/tetratelabs/wazero v1.7.0 github.com/vmihailenco/msgpack/v5 v5.4.1 nhooyr.io/websocket v1.8.10 ) diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum index 4f286ab..a5fd751 100644 --- a/Overlord-Client/go.sum +++ b/Overlord-Client/go.sum @@ -28,8 +28,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tetratelabs/wazero v1.7.0 h1:jg5qPydno59wqjpGrHph81lbtHzTrWzwwtD4cD88+hQ= -github.com/tetratelabs/wazero v1.7.0/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= diff --git a/Overlord-Server/plugins/sample/assets/sample.css b/Overlord-Server/plugins/sample/assets/sample.css new file mode 100644 index 0000000..edc8eaf --- /dev/null +++ b/Overlord-Server/plugins/sample/assets/sample.css @@ -0,0 +1,89 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", system-ui, sans-serif; + background: #0f1115; + color: #e8ecf1; +} + +.container { + max-width: 820px; + margin: 40px auto; + padding: 0 20px; +} + +.header h1 { + margin: 0 0 4px 0; + font-size: 28px; +} + +.sub { + margin: 0 0 24px 0; + color: #9aa4b2; +} + +.card { + background: #161a22; + border: 1px solid #222938; + border-radius: 12px; + padding: 18px; + margin-bottom: 18px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); +} + +.row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; + align-items: center; + margin-bottom: 12px; +} + +.label { + color: #9aa4b2; + font-size: 14px; +} + +.input { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #2a3346; + background: #0f131b; + color: #e8ecf1; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.btn { + background: #3b82f6; + color: #fff; + border: none; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} + +.btn:hover { + background: #2563eb; +} + +.log { + min-height: 120px; + background: #0f131b; + border-radius: 8px; + border: 1px solid #2a3346; + padding: 12px; + white-space: pre-wrap; +} diff --git a/Overlord-Server/plugins/sample/assets/sample.html b/Overlord-Server/plugins/sample/assets/sample.html index 999371b..4cd2e8e 100644 --- a/Overlord-Server/plugins/sample/assets/sample.html +++ b/Overlord-Server/plugins/sample/assets/sample.html @@ -10,7 +10,7 @@

Sample Plugin

-

Go WASM + UI plugin

+

Go Native + UI plugin

diff --git a/Overlord-Server/plugins/sample/manifest.json b/Overlord-Server/plugins/sample/manifest.json new file mode 100644 index 0000000..c2dd18e --- /dev/null +++ b/Overlord-Server/plugins/sample/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "sample", + "name": "sample", + "version": "1.0.0", + "binaries": { + "windows-amd64": "sample-windows-amd64.dll" + }, + "entry": "sample.html", + "assets": { + "html": "sample.html", + "css": "sample.css", + "js": "sample.js" + } +} \ No newline at end of file diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 492e373..c4b572c 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -177,8 +177,8 @@ const ensurePluginExtracted = (pluginId: string) => ensurePluginExtractedFromRoot(PLUGIN_ROOT, pluginId, sanitizePluginId); const listPluginManifests = () => listPluginManifestsFromRoot(PLUGIN_ROOT, pluginState, savePluginState, ensurePluginExtracted); -const loadPluginBundle = (pluginId: string) => - loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted); +const loadPluginBundle = (pluginId: string, clientOS?: string, clientArch?: string) => + loadPluginBundleFromRoot(PLUGIN_ROOT, pluginId, ensurePluginExtracted, clientOS, clientArch); const startBuildProcess = (buildId: string, buildConfig: any) => runBuildProcess(buildId, buildConfig, { generateBuildMutex, diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index dc642a9..16f7b5f 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -241,6 +241,7 @@ export type PluginManifest = { version?: string; description?: string; binary?: string; + binaries?: Record; entry?: string; assets?: { html?: string; diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index a4d4727..4474998 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -72,30 +72,43 @@ export async function ensurePluginExtracted( const zip = new AdmZip(zipPath); const entries = zip.getEntries(); - let wasmEntry: Buffer | null = null; let htmlEntry: Buffer | null = null; let cssEntry: Buffer | null = null; let jsEntry: Buffer | null = null; + const nativeBinaries: Map = new Map(); for (const entry of entries) { if (entry.isDirectory) continue; const base = path.basename(entry.entryName); - if (base.toLowerCase().endsWith(".wasm")) { - wasmEntry = entry.getData(); - } else if (base.toLowerCase().endsWith(".html")) { + const lower = base.toLowerCase(); + if (lower.endsWith(".so") || lower.endsWith(".dll") || lower.endsWith(".dylib")) { + nativeBinaries.set(base, entry.getData()); + } else if (lower.endsWith(".html")) { htmlEntry = entry.getData(); - } else if (base.toLowerCase().endsWith(".css")) { + } else if (lower.endsWith(".css")) { cssEntry = entry.getData(); - } else if (base.toLowerCase().endsWith(".js")) { + } else if (lower.endsWith(".js")) { jsEntry = entry.getData(); } } - if (!wasmEntry || !htmlEntry || !cssEntry || !jsEntry) { - throw new Error(`Invalid plugin bundle: ${safeId} (missing required files)`); + const hasNative = nativeBinaries.size > 0; + if (!hasNative) { + throw new Error(`Invalid plugin bundle: ${safeId} (no native binaries found)`); + } + if (!htmlEntry || !cssEntry || !jsEntry) { + throw new Error(`Invalid plugin bundle: ${safeId} (missing .html, .css, or .js)`); + } + + const binariesMap: Record = {}; + for (const [filename, data] of nativeBinaries) { + await fs.writeFile(path.join(pluginDir, filename), data); + const platformKey = derivePlatformKey(filename); + if (platformKey) { + binariesMap[platformKey] = filename; + } } - await fs.writeFile(path.join(pluginDir, `${safeId}.wasm`), wasmEntry); await fs.writeFile(path.join(assetsDir, `${safeId}.html`), htmlEntry); await fs.writeFile(path.join(assetsDir, `${safeId}.css`), cssEntry); await fs.writeFile(path.join(assetsDir, `${safeId}.js`), jsEntry); @@ -104,7 +117,7 @@ export async function ensurePluginExtracted( id: safeId, name: safeId, version: "1.0.0", - binary: `${safeId}.wasm`, + binaries: binariesMap, entry: `${safeId}.html`, assets: { html: `${safeId}.html`, @@ -168,7 +181,9 @@ export async function loadPluginBundle( pluginRoot: string, pluginId: string, ensureExtracted: (pluginId: string) => Promise, -): Promise<{ manifest: PluginManifest; wasm: Uint8Array }> { + clientOS?: string, + clientArch?: string, +): Promise<{ manifest: PluginManifest; binary: Uint8Array }> { await ensureExtracted(pluginId); const dir = path.join(pluginRoot, pluginId); const manifestPath = path.join(dir, "manifest.json"); @@ -177,30 +192,46 @@ export async function loadPluginBundle( manifest.id = manifest.id || pluginId; manifest.name = manifest.name || pluginId; - const binaryName = manifest.binary || manifest.entry || `${pluginId}.wasm`; - let wasmPath = path.join(dir, binaryName); - try { - await fs.access(wasmPath); - } catch { - const files = await fs.readdir(dir); - const firstWasm = files.find((f) => f.toLowerCase().endsWith(".wasm")); - if (!firstWasm) throw new Error("No .wasm found for plugin " + pluginId); - wasmPath = path.join(dir, firstWasm); + let binaryPath: string | null = null; + + if (manifest.binaries && clientOS && clientArch) { + const key = `${clientOS}-${clientArch}`; + const filename = manifest.binaries[key]; + if (filename) { + const candidate = path.join(dir, filename); + try { + await fs.access(candidate); + binaryPath = candidate; + } catch {} + } } - const wasm = new Uint8Array(await fs.readFile(wasmPath)); - return { manifest, wasm }; + + if (!binaryPath) { + const files = await fs.readdir(dir); + const found = files.find( + (f) => + f.toLowerCase().endsWith(".so") || + f.toLowerCase().endsWith(".dll") || + f.toLowerCase().endsWith(".dylib"), + ); + if (!found) throw new Error(`No plugin binary found for ${pluginId} (${clientOS}-${clientArch})`); + binaryPath = path.join(dir, found); + } + + const binary = new Uint8Array(await fs.readFile(binaryPath)); + return { manifest, binary }; } export function sendPluginBundle( target: ClientInfo, - bundle: { manifest: PluginManifest; wasm: Uint8Array }, + bundle: { manifest: PluginManifest; binary: Uint8Array }, ): void { const chunkSize = 16 * 1024; - const wasm = bundle.wasm; - const totalChunks = Math.ceil(wasm.length / chunkSize); + const data = bundle.binary; + const totalChunks = Math.ceil(data.length / chunkSize); const initPayload = { manifest: bundle.manifest, - size: wasm.length, + size: data.length, chunks: totalChunks, }; target.ws.send( @@ -214,8 +245,8 @@ export function sendPluginBundle( for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; - const end = Math.min(start + chunkSize, wasm.length); - const chunk = wasm.slice(start, end); + const end = Math.min(start + chunkSize, data.length); + const chunk = data.slice(start, end); target.ws.send( encodeMessage({ type: "command", @@ -235,3 +266,15 @@ export function sendPluginBundle( }), ); } + +function derivePlatformKey(filename: string): string { + const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i); + if (match) { + return `${match[1].toLowerCase()}-${match[2].toLowerCase()}`; + } + const lower = filename.toLowerCase(); + if (lower.endsWith(".dll")) return "windows-amd64"; + if (lower.endsWith(".dylib")) return "darwin-amd64"; + if (lower.endsWith(".so")) return "linux-amd64"; + return ""; +} diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 4116ce7..ce96216 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -14,7 +14,7 @@ type PluginManifest = { type PluginBundle = { manifest: PluginManifest; - wasm: Uint8Array; + binary: Uint8Array; }; type PluginState = { @@ -32,7 +32,7 @@ type PluginRouteDeps = { ensurePluginExtracted: (pluginId: string) => Promise; savePluginState: () => Promise; listPluginManifests: () => Promise; - loadPluginBundle: (pluginId: string) => Promise; + loadPluginBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise; sendPluginBundle: (target: any, bundle: PluginBundle) => void; markPluginLoading: (clientId: string, pluginId: string) => void; isPluginLoaded: (clientId: string, pluginId: string) => boolean; @@ -231,7 +231,7 @@ export async function handlePluginRoutes( return Response.json({ ok: true, loading: true }); } try { - const bundle = await deps.loadPluginBundle(pluginId); + const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch); deps.markPluginLoading(targetId, pluginId); deps.sendPluginBundle(target, bundle); metrics.recordCommand("plugin_load"); @@ -276,7 +276,7 @@ export async function handlePluginRoutes( deps.enqueuePluginEvent(targetId, pluginId, event, payload); if (!deps.isPluginLoading(targetId, pluginId)) { try { - const bundle = await deps.loadPluginBundle(pluginId); + const bundle = await deps.loadPluginBundle(pluginId, target.os, target.arch); deps.markPluginLoading(targetId, pluginId); deps.sendPluginBundle(target, bundle); metrics.recordCommand("plugin_load"); diff --git a/PLUGINS.md b/PLUGINS.md index d18b9e8..a9b7ae8 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -1,8 +1,8 @@ # Overlord Plugins -This document explains how to build Overlord plugins, how the runtime works, and what you can do from a plugin UI and a WASM module. +This document explains how to build Overlord plugins using the **native plugin system**. Plugins run as native shared libraries (`.so` on Linux, `.dylib` on macOS, in-memory DLL on Windows), giving them full access to the Go runtime and system APIs. -> TL;DR: A plugin is a zip file with 4 files at the root: `.wasm`, `.html`, `.css`, `.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins. +> TL;DR: A plugin is a zip with platform-specific binaries (`.so`/`.dll`/`.dylib`) plus `.html`, `.css`, `.js`. Upload it in the Plugins page or drop it in Overlord-Server/plugins. ## 1) How plugins are structured @@ -14,49 +14,54 @@ A plugin bundle is a zip file named after the plugin ID: .zip ``` -Inside the zip (root level), these four files are required: +Inside the zip (root level), you need: -``` -.wasm -.html -.css -.js -``` +- **Platform-specific binaries** named `--.` +- **Web assets**: `.html`, `.css`, `.js` Example for plugin ID `sample`: ``` sample.zip - ├─ sample.wasm + ├─ sample-linux-amd64.so + ├─ sample-linux-arm64.so + ├─ sample-darwin-arm64.dylib + ├─ sample-windows-amd64.dll ├─ sample.html ├─ sample.css └─ sample.js ``` -When the server sees `.zip`, it extracts to: +When the server extracts the zip: ``` -Overlord-Server/plugins// - ├─ .wasm - ├─ manifest.json +Overlord-Server/plugins/sample/ + ├─ sample-linux-amd64.so + ├─ sample-linux-arm64.so + ├─ sample-darwin-arm64.dylib + ├─ sample-windows-amd64.dll + ├─ manifest.json (auto-generated) └─ assets/ - ├─ .html - ├─ .css - └─ .js + ├─ sample.html + ├─ sample.css + └─ sample.js ``` -The server **generates manifest.json** automatically. You do not need to include a manifest inside the zip. - ### Manifest fields -The auto-generated manifest looks like: +The auto-generated manifest: -``` +```json { "id": "sample", "name": "sample", "version": "1.0.0", - "binary": "sample.wasm", + "binaries": { + "linux-amd64": "sample-linux-amd64.so", + "linux-arm64": "sample-linux-arm64.so", + "darwin-arm64": "sample-darwin-arm64.dylib", + "windows-amd64": "sample-windows-amd64.dll" + }, "entry": "sample.html", "assets": { "html": "sample.html", @@ -66,47 +71,126 @@ The auto-generated manifest looks like: } ``` -## 2) Build a WASM plugin (Go WASI) +The server picks the right binary for the target client's OS/arch when loading. -The plugin runtime expects a WASI-compatible WebAssembly binary. Go 1.21+ builds work well with the current wazero runtime. +## 2) Build a native plugin -Build steps (from the plugin folder): +### Plugin contract -``` -GOOS=wasip1 GOARCH=wasm go build -o .wasm ./wasm +Plugins are Go packages that export specific functions. The core logic is shared across platforms, with thin platform-specific export wrappers. + +All platforms use `-buildmode=c-shared` and export C-callable functions. + +#### Linux / macOS (`.so` / `.dylib`) + +```go +//export PluginOnLoad +func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.uintptr_t, ctx C.uintptr_t) C.int + +//export PluginOnEvent +func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int + +//export PluginOnUnload +func PluginOnUnload() ``` -There is a working example in: +The host passes a callback function pointer and context during OnLoad. The plugin calls the callback to send events back. -- plugin-sample-go/wasm/main.go +Build: `CGO_ENABLED=1 go build -buildmode=c-shared -o sample-linux-amd64.so ./native` + +**On Linux, shared libraries are loaded entirely in memory via `memfd_create` — no files touch disk.** + +#### Windows (`.dll`) + +```go +//export PluginOnLoad +func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, callbackPtr C.ulonglong) C.int + +//export PluginOnEvent +func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int + +//export PluginOnUnload +func PluginOnUnload() + +//export PluginSetCallback +func PluginSetCallback(callbackPtr C.ulonglong) +``` + +The callback is a stdcall function pointer: `func(eventPtr, eventLen, payloadPtr, payloadLen uintptr) uintptr` + +Build: `CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildmode=c-shared -o sample-windows-amd64.dll ./native` + +**On Windows, DLLs are loaded entirely in memory — no files are written to disk.** + +### HostInfo JSON + +```json +{ + "clientId": "abc123", + "os": "windows", + "arch": "amd64", + "version": "1.0.0" +} +``` + +### Project structure + +See `plugin-sample-go/native/` for a working example: + +``` +plugin-sample-go/native/ + ├─ main.go (shared core logic) + ├─ exports_unix.go (Go plugin exports for Linux/macOS) + ├─ exports_windows.go (C-shared DLL exports for Windows) + └─ go.mod +``` + +### Build scripts + +Use the provided build scripts: + +```bash +# Linux/macOS — builds for current platform by default +./build-plugin.sh + +# Build for multiple targets +BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh + +# Windows — builds windows-amd64 by default +build-plugin.bat + +# Build for multiple targets +set BUILD_TARGETS=windows-amd64 linux-amd64 +build-plugin.bat +``` ## 3) Install & open a plugin ### Install / upload -- Use the UI at /plugins to upload the zip -- Or drop `.zip` into Overlord-Server/plugins and restart the server +- Use the UI at `/plugins` to upload the zip +- Or drop `.zip` into `Overlord-Server/plugins` and restart ### Open the UI -The plugin UI is served from: +Plugin UI is served from: ``` /plugins/?clientId= ``` -Your HTML should load its JS/CSS from `/plugins//assets/`. +Your HTML loads its JS/CSS from `/plugins//assets/`. ## 4) Runtime: how events flow Overlord plugins have **two parts**: -1. **UI (HTML/CSS/JS)** — Runs in the browser and can call server APIs or open WebSockets. -2. **WASM module** — Runs in the agent (client) process via wazero. +1. **UI (HTML/CSS/JS)** — Runs in the browser, calls server APIs. +2. **Native module** — Runs in the agent (client) process as a loaded shared library. ### UI → agent (plugin event) -From your UI JS, you can send an event to a plugin on a client: +From your UI JS: ``` POST /api/clients//plugins//event @@ -116,94 +200,53 @@ POST /api/clients//plugins//event } ``` -If the plugin is not loaded yet, the server will: +If the plugin is not loaded yet, the server will load it on the client, queue the event, and deliver it once ready. -- load it on the client -- queue your event -- deliver it when the plugin reports `loaded` +### Agent → plugin (direct function call) -### Agent → plugin (WASM stdin) +The agent calls your `OnEvent(event, payload)` function directly with JSON-encoded data. No stdin/stdout pipes, no msgpack — just a direct function call. -The agent forwards that event into the WASM plugin over msgpack via stdin. +### Plugin → agent (callback) -The message envelope looks like: +Your plugin sends events back to the host using the `send` callback received during `OnLoad`: -``` -{ - "type": "event", - "event": "ui_message", - "payload": { ... } -} +```go +send("echo", []byte(`{"message":"hello back"}`)) ``` -### Plugin → agent (WASM stdout) - -Your WASM can reply by writing msgpack messages to stdout. Two supported types: - -- `type: "event"` → forwarded back to the server as `plugin_event` -- `type: "log"` → logged by the agent - -Examples: - -``` -{ "type": "event", "event": "ready", "payload": "plugin ready" } -{ "type": "log", "payload": "starting capture" } -``` +On Windows, the equivalent is calling the registered callback function pointer. ### Plugin lifecycle events -The agent sends lifecycle events to the server: +The agent sends these events to the server: - `loaded` on successful load - `unloaded` when unloaded - `error` if load or runtime fails -These update the server-side plugin status and error display. - -> Note: plugin events are **not** pushed back to the plugin UI automatically. If you need UI feedback, call a server API that returns a response to the UI or implement your own polling/WS endpoint. - ## 5) What can plugins do? -Plugins can do anything **the server already exposes** to authenticated users, such as: +Since plugins run as native code, they can: -- start/stop remote desktop -- open a console session -- file browser actions (list, upload, download, edit) -- process listing and kill -- run scripts -- send commands to clients +- Call any system API (file I/O, network, processes, etc.) +- Use any Go library +- Spawn goroutines +- Access hardware +- Do anything a normal Go program can do -This is intentional: plugins **do not modify server code** and are sandboxed by the plugin CSP. +Plugins have the same capabilities as the agent itself. -### Security constraints +### UI Security constraints -Plugin pages are served with a tight CSP: +Plugin UI pages are still served with a tight CSP: -- scripts must be same-origin -- no third‑party JS/CDN +- Scripts must be same-origin +- No third-party JS/CDN - WebSocket and fetch are allowed to same origin -That means your plugin JS must be bundled into `.js` and loaded from `/plugins//assets/`. +Plugin UIs run in a **sandboxed iframe** with a fetch bridge. -### Sandboxed iframe isolation - -Plugin UIs are rendered inside a **sandboxed iframe** to isolate them from the main dashboard. This provides: - -- No access to main app DOM -- No access to main app cookies/localStorage -- No direct access to privileged APIs - -Because of the sandbox, **direct network calls from the plugin are blocked** by CSP. The system injects a small bridge that replaces `window.fetch` and forwards requests to the parent page. - -Practical implications: - -- Use `fetch()` as usual in your plugin JS -- Only the allowed plugin API routes are permitted -- Direct WebSocket usage from the plugin frame is blocked - -If your plugin needs WebSockets (e.g., remote desktop), you must build a parent‑page bridge that opens the WS in the parent and forwards frames into the plugin UI. See the remote desktop section below for the architecture. - -## 6) API surface (what you can call) +## 6) API surface ### Plugin management @@ -220,78 +263,10 @@ If your plugin needs WebSockets (e.g., remote desktop), you must build a parent ### Useful built-in endpoints -- `POST /api/clients//command` (input, ping, scripts, etc.) +- `POST /api/clients//command` - `WS /api/clients//rd/ws` (remote desktop) - `WS /api/clients//console/ws` - `WS /api/clients//files/ws` - `WS /api/clients//processes/ws` -## 7) Building a “pinnacle” plugin (remote desktop class) -A remote‑desktop‑class plugin typically has: - -1. **A UI page** with a `` and quality controls -2. **A WebSocket** connection to `/api/clients//rd/ws` -3. **Command messages** (`desktop_start`, `desktop_stop`, `desktop_select_display`, etc.) -4. **Binary frame decoding** (JPEG/raw) and drawing into the canvas - -### How it would work (high level) - -1. UI page reads `clientId` from query string -2. JS opens a WebSocket to `/api/clients//rd/ws` -3. UI sends JSON messages: - -``` -{ "type": "desktop_start" } -{ "type": "desktop_select_display", "display": 0 } -{ "type": "desktop_set_quality", "quality": 90, "codec": "jpeg" } -``` - -4. Incoming WS messages contain encoded frames (see `public/assets/remotedesktop.js` for the parsing logic) -5. Draw frames into the canvas and expose controls to the user - -You do **not** need to re-implement the agent capture pipeline. The agent already supports it; your plugin just drives the existing WS endpoint. - -### What you’d write (outline) - -- **HTML**: Canvas + controls -- **JS**: - - parse `clientId` - - open WS - - send desktop commands - - decode frames and draw -- **WASM module** (optional): - - background tasks or message validation - - optional telemetry / state management - -If you want a starting point, the existing remote desktop UI is in: - -- Overlord-Server/public/remotedesktop.html -- Overlord-Server/public/assets/remotedesktop.js - -You can reuse that logic inside your plugin UI with minimal changes. - -## 8) Example: minimal plugin code shape - -### WASM side (Go, message loop) - -- listen on stdin using msgpack -- on `type=init`, send `event=ready` -- on `type=event`, process payload and emit response events - -See: - -- plugin-sample-go/wasm/main.go - -### UI side (JS) - -- call `/api/clients//plugins//event` -- show responses via your own UI (or poll your own endpoints) - -See: - -- plugin-sample-go/sample.js - ---- - -If you want a deeper walkthrough of a specific plugin type (file explorer, custom telemetry, or a remote-desktop clone), say which one and I’ll expand that section. diff --git a/build-plugin.bat b/build-plugin.bat index 646f61f..2e9ae87 100644 --- a/build-plugin.bat +++ b/build-plugin.bat @@ -5,40 +5,83 @@ set "ROOT=%~dp0" set "PLUGIN_DIR=%ROOT%plugin-sample-go" if not "%~1"=="" set "PLUGIN_DIR=%~1" -set "WASM_DIR=%PLUGIN_DIR%\wasm" +set "NATIVE_DIR=%PLUGIN_DIR%\native" set "PLUGIN_NAME=sample" -set "WASM_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.wasm" set "ZIP_OUT=%PLUGIN_DIR%\%PLUGIN_NAME%.zip" -if not exist "%WASM_DIR%" ( - echo [error] wasm folder not found: %WASM_DIR% +if not exist "%NATIVE_DIR%" ( + echo [error] native folder not found: %NATIVE_DIR% exit /b 1 ) -pushd "%WASM_DIR%" +pushd "%NATIVE_DIR%" -echo [build] go build -o "%WASM_OUT%" (GOOS=wasip1 GOARCH=wasm) -set "GOOS=wasip1" -set "GOARCH=wasm" -go build -o "%WASM_OUT%" . -set "GOOS=" -set "GOARCH=" -if errorlevel 1 ( - echo [warn] go build failed, trying tinygo - echo [build] tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking . - tinygo build -o "%WASM_OUT%" -target wasi -scheduler=none -gc=leaking . +REM Build targets - default to windows-amd64 on Windows +if not defined BUILD_TARGETS set "BUILD_TARGETS=windows-amd64" + +set "BUILT_FILES=" +for %%T in (%BUILD_TARGETS%) do ( + for /f "tokens=1,2 delims=-" %%A in ("%%T") do ( + set "TARGET_OS=%%A" + set "TARGET_ARCH=%%B" + ) + + if "!TARGET_OS!"=="windows" ( + set "EXT=dll" + ) else if "!TARGET_OS!"=="darwin" ( + set "EXT=dylib" + ) else ( + set "EXT=so" + ) + set "BUILDMODE=c-shared" + + set "OUTFILE=%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.!EXT!" + echo [build] GOOS=!TARGET_OS! GOARCH=!TARGET_ARCH! CGO_ENABLED=1 go build -buildmode=!BUILDMODE! -o "!OUTFILE!" + set "GOOS=!TARGET_OS!" + set "GOARCH=!TARGET_ARCH!" + set "CGO_ENABLED=1" + go build -buildmode=!BUILDMODE! -o "!OUTFILE!" . if errorlevel 1 ( - echo [error] tinygo build failed + echo [error] build failed for !TARGET_OS!-!TARGET_ARCH! popd exit /b 1 ) + set "BUILT_FILES=!BUILT_FILES! '%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.!EXT!'" ) +set "GOOS=" +set "GOARCH=" +set "CGO_ENABLED=" + popd if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%" -powershell -NoProfile -Command "Compress-Archive -Path '%PLUGIN_DIR%\%PLUGIN_NAME%.wasm','%PLUGIN_DIR%\%PLUGIN_NAME%.html','%PLUGIN_DIR%\%PLUGIN_NAME%.css','%PLUGIN_DIR%\%PLUGIN_NAME%.js' -DestinationPath '%ZIP_OUT%'" +REM Collect all build outputs and web assets +set "ZIP_SOURCES=" +for %%T in (%BUILD_TARGETS%) do ( + for /f "tokens=1,2 delims=-" %%A in ("%%T") do ( + set "TARGET_OS=%%A" + set "TARGET_ARCH=%%B" + ) + if "!TARGET_OS!"=="windows" ( + set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dll'" + ) else if "!TARGET_OS!"=="darwin" ( + set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dylib'" + ) else ( + set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.so'" + ) +) + +REM Add web assets +if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.html" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.html'" +if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.css" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.css'" +if exist "%PLUGIN_DIR%\%PLUGIN_NAME%.js" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%\%PLUGIN_NAME%.js'" + +REM Remove leading comma +set "ZIP_SOURCES=!ZIP_SOURCES:~1!" + +powershell -NoProfile -Command "Compress-Archive -Path !ZIP_SOURCES! -DestinationPath '%ZIP_OUT%'" if errorlevel 1 ( echo [error] zip failed exit /b 1 diff --git a/build-plugin.sh b/build-plugin.sh index 06a5991..dbac823 100644 --- a/build-plugin.sh +++ b/build-plugin.sh @@ -3,31 +3,65 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_DIR="${1:-${ROOT_DIR}/plugin-sample-go}" -WASM_DIR="${PLUGIN_DIR}/wasm" +NATIVE_DIR="${PLUGIN_DIR}/native" PLUGIN_NAME="sample" -WASM_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.wasm" ZIP_OUT="${PLUGIN_DIR}/${PLUGIN_NAME}.zip" -if [[ ! -d "${WASM_DIR}" ]]; then - echo "[error] wasm folder not found: ${WASM_DIR}" >&2 +if [[ ! -d "${NATIVE_DIR}" ]]; then + echo "[error] native folder not found: ${NATIVE_DIR}" >&2 exit 1 fi -pushd "${WASM_DIR}" >/dev/null +pushd "${NATIVE_DIR}" >/dev/null -echo "[build] go build -o ${WASM_OUT} (GOOS=wasip1 GOARCH=wasm)" -if ! GOOS=wasip1 GOARCH=wasm go build -o "${WASM_OUT}" .; then - echo "[warn] go build failed, trying tinygo" - echo "[build] tinygo build -o ${WASM_OUT} -target wasi -scheduler=none -gc=leaking ." - tinygo build -o "${WASM_OUT}" -target wasi -scheduler=none -gc=leaking . -fi +# Detect current OS/arch for default build +HOST_OS="$(go env GOOS)" +HOST_ARCH="$(go env GOARCH)" + +# Build matrix - can be overridden with BUILD_TARGETS env var +# Format: "os-arch os-arch ..." e.g. "linux-amd64 linux-arm64 darwin-arm64" +DEFAULT_TARGETS="${HOST_OS}-${HOST_ARCH}" +BUILD_TARGETS="${BUILD_TARGETS:-${DEFAULT_TARGETS}}" + +BUILT_FILES=() + +for target in ${BUILD_TARGETS}; do + os="${target%%-*}" + arch="${target#*-}" + + if [[ "${os}" == "windows" ]]; then + ext="dll" + elif [[ "${os}" == "darwin" ]]; then + ext="dylib" + else + ext="so" + fi + buildmode="c-shared" + + outfile="${PLUGIN_DIR}/${PLUGIN_NAME}-${os}-${arch}.${ext}" + echo "[build] GOOS=${os} GOARCH=${arch} CGO_ENABLED=1 go build -buildmode=${buildmode} -o ${outfile}" + CGO_ENABLED=1 GOOS="${os}" GOARCH="${arch}" go build -buildmode="${buildmode}" -o "${outfile}" . + BUILT_FILES+=("${PLUGIN_NAME}-${os}-${arch}.${ext}") +done popd >/dev/null rm -f "${ZIP_OUT}" +# Collect files to zip +ZIP_FILES=() +for bf in "${BUILT_FILES[@]}"; do + ZIP_FILES+=("${bf}") +done +# Add web assets +for asset in "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js"; do + if [[ -f "${PLUGIN_DIR}/${asset}" ]]; then + ZIP_FILES+=("${asset}") + fi +done + if command -v zip >/dev/null 2>&1; then - (cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${PLUGIN_NAME}.wasm" "${PLUGIN_NAME}.html" "${PLUGIN_NAME}.css" "${PLUGIN_NAME}.js") + (cd "${PLUGIN_DIR}" && zip -q "${ZIP_OUT}" "${ZIP_FILES[@]}") else echo "[error] zip not found. Please install zip." >&2 exit 1 diff --git a/go.work b/go.work index 6212492..d342da8 100644 --- a/go.work +++ b/go.work @@ -2,5 +2,5 @@ go 1.24 use ( ./Overlord-Client - ./plugin-sample-go/wasm + ./plugin-sample-go/native ) diff --git a/plugin-sample-go/README.md b/plugin-sample-go/README.md index b82c812..3d381ed 100644 --- a/plugin-sample-go/README.md +++ b/plugin-sample-go/README.md @@ -1,49 +1,49 @@ -# Sample Go WASM Plugin (sample) +# Sample Go Plugin (sample) -This sample lives at the repo root and builds a WASI-compatible Go plugin. +This sample lives at the repo root and builds a native Go plugin. ## Files to include in the plugin zip At the root of the zip: -- sample.wasm +- sample-linux-amd64.so / sample-darwin-arm64.dylib / sample-windows-amd64.dll - sample.html - sample.css - sample.js -## Build the WASM +## Build -Recommended: Go WASI (most compatible with wazero) +Use the provided build scripts: -1) Build the WASM with Go 1.21+: +```bash +# Linux/macOS +./build-plugin.sh - GOOS=wasip1 GOARCH=wasm go build -o sample.wasm ./wasm +# Multiple targets +BUILD_TARGETS="linux-amd64 linux-arm64 darwin-arm64" ./build-plugin.sh -2) Optional TinyGo fallback: - - tinygo build -o sample.wasm -target wasi -scheduler=none -gc=leaking ./wasm +# Windows +build-plugin.bat +``` ## Create the zip -Create a zip named sample.zip containing: -- sample.wasm -- sample.html -- sample.css -- sample.js +The build script automatically creates `sample.zip` containing the built +binaries and web assets. -Then place sample.zip in Overlord-Server/plugins. +Then place `sample.zip` in `Overlord-Server/plugins`. ## Open the UI Navigate to: - /plugins/sample?clientId= -Click "Send event" and the WASM plugin will echo back to the server logs. +Click "Send event" and the plugin will echo back to the server logs. ## OS detection (Windows-only logic) The plugin receives host metadata in the `init` payload. The sample plugin shows how to detect the host OS and branch logic (see -[plugin-sample-go/wasm/main.go](plugin-sample-go/wasm/main.go)). For example: +`plugin-sample-go/native/main.go`). For example: - If `host.os == "windows"`, you can safely run Windows-only flows (e.g., WinAPI-backed behavior). - Otherwise, skip or provide a fallback path. diff --git a/plugin-sample-go/native/exports_unix.go b/plugin-sample-go/native/exports_unix.go new file mode 100644 index 0000000..f2f94db --- /dev/null +++ b/plugin-sample-go/native/exports_unix.go @@ -0,0 +1,75 @@ +//go:build linux || darwin || freebsd + +package main + +/* +#include + +typedef void (*host_callback_t)(uintptr_t ctx, + const char* event, int eventLen, + const char* payload, int payloadLen); + +static void invoke_host_callback(uintptr_t fn, uintptr_t ctx, + const char* ev, int evLen, const char* pl, int plLen) { + ((host_callback_t)fn)(ctx, ev, evLen, pl, plLen); +} +*/ +import "C" + +import "unsafe" + +var ( + hostCallbackFn C.uintptr_t + hostCallbackCtx C.uintptr_t +) + +func hostSendViaCallback(event string, payload []byte) { + fn := hostCallbackFn + if fn == 0 { + return + } + evBytes := []byte(event) + var evPtr, plPtr unsafe.Pointer + if len(evBytes) > 0 { + evPtr = unsafe.Pointer(&evBytes[0]) + } + if len(payload) > 0 { + plPtr = unsafe.Pointer(&payload[0]) + } + C.invoke_host_callback(fn, hostCallbackCtx, + (*C.char)(evPtr), C.int(len(evBytes)), + (*C.char)(plPtr), C.int(len(payload))) +} + +//export PluginOnLoad +func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.uintptr_t, ctx C.uintptr_t) C.int { + hostCallbackFn = cb + hostCallbackCtx = ctx + setSend(hostSendViaCallback) + + data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen) + if err := handleInit(data); err != nil { + return 1 + } + return 0 +} + +//export PluginOnEvent +func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int { + ev := C.GoStringN(event, eventLen) + var pl []byte + if payloadLen > 0 { + pl = C.GoBytes(unsafe.Pointer(payload), payloadLen) + } + if err := handleEvent(ev, pl); err != nil { + return 1 + } + return 0 +} + +//export PluginOnUnload +func PluginOnUnload() { + handleUnload() +} + +func main() {} diff --git a/plugin-sample-go/native/exports_windows.go b/plugin-sample-go/native/exports_windows.go new file mode 100644 index 0000000..298f1cd --- /dev/null +++ b/plugin-sample-go/native/exports_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package main + +import "C" +import ( + "syscall" + "unsafe" +) + +var callbackPtr uintptr + +func hostSendViaCallback(event string, payload []byte) { + cb := callbackPtr + if cb == 0 { + return + } + eventBytes := []byte(event) + var evPtr, plPtr uintptr + evLen := uintptr(len(eventBytes)) + plLen := uintptr(len(payload)) + if len(eventBytes) > 0 { + evPtr = uintptr(unsafe.Pointer(&eventBytes[0])) + } + if len(payload) > 0 { + plPtr = uintptr(unsafe.Pointer(&payload[0])) + } + syscall.SyscallN(cb, evPtr, evLen, plPtr, plLen) +} + +//export PluginSetCallback +func PluginSetCallback(cb C.ulonglong) { + callbackPtr = uintptr(cb) + setSend(hostSendViaCallback) +} + +//export PluginOnLoad +func PluginOnLoad(hostInfo *C.char, hostInfoLen C.int, cb C.ulonglong) C.int { + callbackPtr = uintptr(cb) + setSend(hostSendViaCallback) + + data := C.GoBytes(unsafe.Pointer(hostInfo), hostInfoLen) + if err := handleInit(data); err != nil { + return 1 + } + return 0 +} + +//export PluginOnEvent +func PluginOnEvent(event *C.char, eventLen C.int, payload *C.char, payloadLen C.int) C.int { + ev := C.GoStringN(event, eventLen) + var pl []byte + if payloadLen > 0 { + pl = C.GoBytes(unsafe.Pointer(payload), payloadLen) + } + if err := handleEvent(ev, pl); err != nil { + return 1 + } + return 0 +} + +//export PluginOnUnload +func PluginOnUnload() { + handleUnload() +} + +func main() {} diff --git a/plugin-sample-go/native/go.mod b/plugin-sample-go/native/go.mod new file mode 100644 index 0000000..9a85d06 --- /dev/null +++ b/plugin-sample-go/native/go.mod @@ -0,0 +1,3 @@ +module sample-plugin + +go 1.24 diff --git a/plugin-sample-go/native/main.go b/plugin-sample-go/native/main.go new file mode 100644 index 0000000..899c073 --- /dev/null +++ b/plugin-sample-go/native/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "log" + "sync" +) + +type HostInfo struct { + ClientID string `json:"clientId"` + OS string `json:"os"` + Arch string `json:"arch"` + Version string `json:"version"` +} + +var ( + hostInfo HostInfo + sendFn func(event string, payload []byte) + mu sync.Mutex +) + +func setSend(fn func(event string, payload []byte)) { + mu.Lock() + sendFn = fn + mu.Unlock() +} + +func sendEvent(event string, payload interface{}) { + mu.Lock() + fn := sendFn + mu.Unlock() + if fn == nil { + return + } + data, err := json.Marshal(payload) + if err != nil { + log.Printf("[sample] marshal error: %v", err) + return + } + fn(event, data) +} + +func handleInit(hostJSON []byte) error { + if err := json.Unmarshal(hostJSON, &hostInfo); err != nil { + return err + } + log.Printf("[sample] init: clientId=%s os=%s arch=%s", hostInfo.ClientID, hostInfo.OS, hostInfo.Arch) + sendEvent("ready", map[string]string{"message": "sample plugin ready"}) + return nil +} + +func handleEvent(event string, payload []byte) error { + switch event { + case "ui_message": + var msg struct { + Message string `json:"message"` + } + _ = json.Unmarshal(payload, &msg) + log.Printf("[sample] got ui_message: %s", msg.Message) + sendEvent("echo", map[string]string{"message": msg.Message}) + + case "ping": + sendEvent("pong", nil) + + default: + log.Printf("[sample] unhandled event: %s", event) + } + return nil +} + +func handleUnload() { + log.Printf("[sample] unloading") +} diff --git a/plugin-sample-go/native/sample-plugin.h b/plugin-sample-go/native/sample-plugin.h new file mode 100644 index 0000000..2cc5ccc --- /dev/null +++ b/plugin-sample-go/native/sample-plugin.h @@ -0,0 +1,92 @@ +/* Code generated by cmd/cgo; DO NOT EDIT. */ + +/* package sample-plugin */ + + +#line 1 "cgo-builtin-export-prolog" + +#include + +#ifndef GO_CGO_EXPORT_PROLOGUE_H +#define GO_CGO_EXPORT_PROLOGUE_H + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef struct { const char *p; ptrdiff_t n; } _GoString_; +extern size_t _GoStringLen(_GoString_ s); +extern const char *_GoStringPtr(_GoString_ s); +#endif + +#endif + +/* Start of preamble from import "C" comments. */ + + + + +/* End of preamble from import "C" comments. */ + + +/* Start of boilerplate cgo prologue. */ +#line 1 "cgo-gcc-export-header-prolog" + +#ifndef GO_CGO_PROLOGUE_H +#define GO_CGO_PROLOGUE_H + +typedef signed char GoInt8; +typedef unsigned char GoUint8; +typedef short GoInt16; +typedef unsigned short GoUint16; +typedef int GoInt32; +typedef unsigned int GoUint32; +typedef long long GoInt64; +typedef unsigned long long GoUint64; +typedef GoInt64 GoInt; +typedef GoUint64 GoUint; +typedef size_t GoUintptr; +typedef float GoFloat32; +typedef double GoFloat64; +#ifdef _MSC_VER +#if !defined(__cplusplus) || _MSVC_LANG <= 201402L +#include +typedef _Fcomplex GoComplex64; +typedef _Dcomplex GoComplex128; +#else +#include +typedef std::complex GoComplex64; +typedef std::complex GoComplex128; +#endif +#else +typedef float _Complex GoComplex64; +typedef double _Complex GoComplex128; +#endif + +/* + static assertion to make sure the file is being used on architecture + at least with matching size of GoInt. +*/ +typedef char _check_for_64_bit_pointer_matching_GoInt[sizeof(void*)==64/8 ? 1:-1]; + +#ifndef GO_CGO_GOSTRING_TYPEDEF +typedef _GoString_ GoString; +#endif +typedef void *GoMap; +typedef void *GoChan; +typedef struct { void *t; void *v; } GoInterface; +typedef struct { void *data; GoInt len; GoInt cap; } GoSlice; + +#endif + +/* End of boilerplate cgo prologue. */ + +#ifdef __cplusplus +extern "C" { +#endif + +extern __declspec(dllexport) void PluginSetCallback(long long unsigned int cb); +extern __declspec(dllexport) int PluginOnLoad(char* hostInfo, int hostInfoLen, long long unsigned int cb); +extern __declspec(dllexport) int PluginOnEvent(char* event, int eventLen, char* payload, int payloadLen); +extern __declspec(dllexport) void PluginOnUnload(void); + +#ifdef __cplusplus +} +#endif diff --git a/plugin-sample-go/sample.html b/plugin-sample-go/sample.html index 999371b..4cd2e8e 100644 --- a/plugin-sample-go/sample.html +++ b/plugin-sample-go/sample.html @@ -10,7 +10,7 @@

Sample Plugin

-

Go WASM + UI plugin

+

Go Native + UI plugin

diff --git a/plugin-sample-go/wasm/go.mod b/plugin-sample-go/wasm/go.mod deleted file mode 100644 index 8e2fd88..0000000 --- a/plugin-sample-go/wasm/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module plugin-sample-go/wasm - -go 1.21 - -require github.com/vmihailenco/msgpack/v5 v5.4.1 diff --git a/plugin-sample-go/wasm/main.go b/plugin-sample-go/wasm/main.go deleted file mode 100644 index 9c58604..0000000 --- a/plugin-sample-go/wasm/main.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "log" - "os" - - "github.com/vmihailenco/msgpack/v5" -) - -type Incoming struct { - Type string `msgpack:"type"` - Event string `msgpack:"event,omitempty"` - Payload interface{} `msgpack:"payload,omitempty"` -} - -type Outgoing struct { - Type string `msgpack:"type"` - Event string `msgpack:"event,omitempty"` - Payload interface{} `msgpack:"payload,omitempty"` -} - -type UiMessage struct { - Message string `msgpack:"message"` -} - -type HostInfo struct { - ClientID string `msgpack:"clientId"` - OS string `msgpack:"os"` - Arch string `msgpack:"arch"` - Version string `msgpack:"version"` -} - -type InitPayload struct { - Host HostInfo `msgpack:"host"` -} - -func main() { - dec := msgpack.NewDecoder(os.Stdin) - enc := msgpack.NewEncoder(os.Stdout) - - for { - var msg Incoming - if err := dec.Decode(&msg); err != nil { - log.Printf("decode error: %v", err) - return - } - - switch msg.Type { - case "init": - var init InitPayload - if payloadBytes, err := msgpack.Marshal(msg.Payload); err == nil { - _ = msgpack.Unmarshal(payloadBytes, &init) - } - - osName := init.Host.OS - if osName == "" { - osName = "unknown" - } - - if osName == "windows" { - log.Printf("[sample] windows detected - you can use WinAPI-specific flows") - _ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready (windows detected)"}) - } else { - log.Printf("[sample] non-windows host detected (%s)", osName) - _ = enc.Encode(Outgoing{Type: "event", Event: "ready", Payload: "sample plugin ready"}) - } - case "event": - if msg.Event == "ui_message" { - payloadBytes, _ := msgpack.Marshal(msg.Payload) - var ui UiMessage - _ = msgpack.Unmarshal(payloadBytes, &ui) - _ = enc.Encode(Outgoing{Type: "event", Event: "echo", Payload: ui.Message}) - } - } - } -}