Start work on lua based plugin system
Some checks failed
/ docker (push) Has been cancelled

This commit is contained in:
Melody Becker 2025-06-02 17:40:53 +02:00
parent bf0aaaca8f
commit 94106bb82f
Signed by: mstar
SSH key fingerprint: SHA256:9VAo09aaVNTWKzPW7Hq2LW+ox9OdwmTSHRoD4mlz1yI
8 changed files with 430 additions and 1 deletions

12
plugins/api.go Normal file
View file

@ -0,0 +1,12 @@
package plugins
import lua "github.com/yuin/gopher-lua"
// TODO: Decide on the API made available to plugins
// Everything has to be a function, assume no internal state
// since the used lua state may vary between calls
type linstromApi struct{}
func insertLinstromApiIntoState(l *lua.LState) error {
panic("not implemented")
}

62
plugins/init.go Normal file
View file

@ -0,0 +1,62 @@
package plugins
import (
"fmt"
"git.mstar.dev/mstar/goutils/other"
"github.com/PeerDB-io/gluabit32"
"github.com/cjoudrey/gluahttp"
"github.com/cosmotek/loguago"
"github.com/kohkimakimoto/gluatemplate"
gluajson "github.com/layeh/gopher-json"
"github.com/rs/zerolog/log"
"github.com/yuin/gluare"
lua "github.com/yuin/gopher-lua"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
// "github.com/CuberL/glua-async" // For async support, see module readme for how to
// Use "github.com/layeh/gopher-luar" for implementing interface
// Create a new "blank" lua state for future use.
// Each created state has to be properly closed.
// A "blank" state has the base libraries preloaded and is technically ready to use.
// Plugin specific functions and data is not yet included
func NewBlankState() (*lua.LState, error) {
l := lua.NewState()
for _, pair := range []struct {
n string
f lua.LGFunction
}{
{lua.LoadLibName, lua.OpenPackage}, // Must always be first
{lua.BaseLibName, lua.OpenBase},
{lua.TabLibName, lua.OpenTable},
// {lua.IoLibName, lua.OpenIo},
{lua.OsLibName, lua.OpenOs},
{lua.StringLibName, lua.OpenString},
{lua.MathLibName, lua.OpenMath},
// {lua.DebugLibName, lua.OpenDebug},
{lua.ChannelLibName, lua.OpenChannel},
{lua.CoroutineLibName, lua.OpenCoroutine},
{"bit32", gluabit32.Loader},
{"json", gluajson.Loader},
{"log", loguago.NewLogger(log.Logger).Loader}, // TODO: Decide if used logger should be passed in via context
{"http", gluahttp.NewHttpModule(&webshared.RequestClient).Loader}, // TODO: Modify this to support signing
{"re", gluare.Loader},
{"texttemplate", gluatemplate.Loader}, // TODO: Copy this lib, but modify to use html/template instead
} {
if err := l.CallByParam(lua.P{
Fn: l.NewFunction(pair.f),
NRet: 0,
Protect: true,
}, lua.LString(pair.n)); err != nil {
return nil, other.Error(
"plugins",
fmt.Sprintf("Failed to preload lua library %q into state", pair.n),
err,
)
}
}
return l, nil
}

67
plugins/pluginHolder.go Normal file
View file

@ -0,0 +1,67 @@
package plugins
import lua "github.com/yuin/gopher-lua"
/*
Plugin hooks:
- Pre local message
- Post local message
- Pre external message (received but not processed by default stuff)
- Post external message (received, internal processing done)
- Post new local account
- Pre new remote account discovered
- Post new remote account discovered
*/
type pluginHolder struct {
meta *PluginMetadata
table *lua.LTable
// ---- Section hooks
postLocalMessage *lua.LFunction
postExternalMessage *lua.LFunction
postNewLocalAccount *lua.LFunction
postRemoteAccountDiscovered *lua.LFunction
// preLocalMessage *lua.LFunction
// preExternalMessage *lua.LFunction // Might not want this
// preRemoteAccountDiscovered *lua.LFunction
}
type PluginMetadata struct {
Name string `json:"name"`
Version string `json:"version"`
// Don't load major and minor versions from meta file, calc from full version instead
Major int `json:"-"`
Minor int `json:"-"`
Source string `json:"source"`
License string `json:"license"`
Hashes map[string]string `json:"hashes"`
EntryFile string `json:"entry"`
HookNames map[string]string `json:"hook_names"`
}
const (
HookPostLocalMessage = "post-local-message"
HookPostExternalMessage = "post-external-message"
HookPostNewLocalAccount = "post-new-local-account"
HookPostRemoteAccountDiscovered = "post-remote-account-discovered"
)
// TODO: Figure out signature
func (p *pluginHolder) PostLocalMessage(l *lua.LState) error {
// Only act on the hook if a hook exists
if p.postLocalMessage != nil {
err := l.CallByParam(lua.P{
Fn: p.postLocalMessage,
NRet: 1, // TODO: Adjust based on signature
Protect: true,
}) // TODO: Translate signature arguments into LValues and include here
if err != nil {
return err
}
// TODO: Translate args on stack back into whats expected from the signature
panic("not implemented")
}
return nil
}

200
plugins/pluginLoader.go Normal file
View file

@ -0,0 +1,200 @@
package plugins
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"sync"
"git.mstar.dev/mstar/goutils/other"
lua "github.com/yuin/gopher-lua"
"github.com/yuin/gopher-lua/parse"
)
/*
Plugin structure expectation:
- meta.json for plugin metadata:
- version
- name
- source location (where the plugin can be sourced from)
- license
- file hashes (every file in the plugin dir, sha256, hex encoded)
- entry file name
- Hook to table key name list (which function in the returned table is for what hook)
- Entry script returns a table
*/
const PluginMetadataFileName = "meta.json"
var (
ErrNoHashForFile = errors.New("no hash for file in the metadata")
ErrInvalidHash = errors.New("hashes don't match")
ErrMissingFile = errors.New("a required file is missing")
ErrInvalidEntryReturnValue = errors.New(
"entry script returned an invalid value. Must be a table",
)
)
var compCache = map[string]*lua.FunctionProto{}
var compCacheLock = sync.RWMutex{}
// Load the plugin located in a provided folder into the given state.
// Validates the data, compiles it for future reuse and links up the hooks
func LoadPlugin(location string, lState *lua.LState) (*pluginHolder, error) {
metaFileData, err := os.ReadFile(location + "/" + PluginMetadataFileName)
if err != nil {
return nil, err
}
metadata := PluginMetadata{}
err = json.Unmarshal(metaFileData, &metadata)
if err != nil {
return nil, err
}
compCacheLock.RLock()
precompiled, ok := compCache[metadata.Name+metadata.Version]
compCacheLock.RUnlock()
if !ok {
// This version of a plugin not in the cache yet, validate the plugin dir, compile it and add it to the cache
precompiled, err = compilePluginDir(&metadata, location)
if err != nil {
return nil, err
}
compCacheLock.Lock()
compCache[metadata.Name+metadata.Version] = precompiled
compCacheLock.Unlock()
}
lFunc := lState.NewFunctionFromProto(precompiled)
lState.Push(lFunc)
if err = lState.PCall(0, lua.MultRet, nil); err != nil {
return nil, err
}
return generatePluginHolder(lState, &metadata)
}
// Compiles a given lua file for future use
func compileFile(filePath string) (*lua.FunctionProto, error) {
file, err := os.Open(filePath)
defer func() { _ = file.Close() }()
if err != nil {
return nil, err
}
reader := bufio.NewReader(file)
chunk, err := parse.Parse(reader, filePath)
if err != nil {
return nil, err
}
proto, err := lua.Compile(chunk, filePath)
if err != nil {
return nil, err
}
return proto, nil
}
// Validates the given plugin dir and compiles it for future use
func compilePluginDir(metadata *PluginMetadata, location string) (*lua.FunctionProto, error) {
if err := checkDir(location, "", metadata.Hashes); err != nil {
return nil, err
}
entryFileName := path.Join(location, metadata.EntryFile)
if _, err := os.Stat(entryFileName); err != nil {
return nil, other.Error("plugins", fmt.Sprintf("missing entry file %s", entryFileName), err)
}
compiled, err := compileFile(entryFileName)
if err != nil {
return nil, err
}
return compiled, nil
}
// Ensures that all files in the given root directory (plus suffix) match the expected hashes provided in the metadata
func checkDir(rootPath string, suffix string, expectedHashes map[string]string) error {
rootEntries, err := os.ReadDir(path.Join(rootPath, suffix))
if err != nil {
return err
}
for _, entry := range rootEntries {
if suffix == "" && entry.Name() == PluginMetadataFileName {
continue
}
if entry.IsDir() {
if err = checkDir(rootPath, path.Join(suffix, entry.Name()), expectedHashes); err != nil {
return err
}
}
fileName := path.Join(rootPath, suffix, entry.Name())
expectedHash, ok := expectedHashes[fileName]
if !ok {
return other.Error(
"plugins",
fmt.Sprintf("No hash found in metadata for file %s", fileName),
ErrNoHashForFile,
)
}
f, err := os.Open(fileName)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
if err = checkHash(f, expectedHash); err != nil {
return err
}
}
return nil
}
// Checks if a hash matches the given file
func checkHash(file *os.File, expected string) error {
hasher := sha256.New()
_, err := io.Copy(hasher, file)
if err != nil {
return err
}
sum := hasher.Sum([]byte{})
if expected != hex.EncodeToString(sum) {
return ErrInvalidHash
}
return nil
}
// Properly inserts the plugin into the state's global context.
// Also gets links to all functions found that are named as hook in the metadata.
// Assumptions: Called after the entry file has been run, but before any other operations
func generatePluginHolder(l *lua.LState, meta *PluginMetadata) (*pluginHolder, error) {
pluginTable, ok := l.Get(1).(*lua.LTable)
if !ok || pluginTable.MaxN() != 0 {
return nil, ErrInvalidEntryReturnValue
}
l.SetGlobal(meta.Name, pluginTable)
pMap := lTableToMap(pluginTable)
holder := pluginHolder{
meta: meta,
table: pluginTable,
}
if postLocalMessageHookName, ok := meta.HookNames[HookPostLocalMessage]; ok {
if lV, ok := pMap[lua.LString(postLocalMessageHookName)].(*lua.LFunction); ok {
holder.postLocalMessage = lV
}
}
if postExternalMessageHookName, ok := meta.HookNames[HookPostExternalMessage]; ok {
if lV, ok := pMap[lua.LString(postExternalMessageHookName)].(*lua.LFunction); ok {
holder.postExternalMessage = lV
}
}
if postNewLocalAccountName, ok := meta.HookNames[HookPostNewLocalAccount]; ok {
if lV, ok := pMap[lua.LString(postNewLocalAccountName)].(*lua.LFunction); ok {
holder.postNewLocalAccount = lV
}
}
if postRemoteAccountDiscoveredName, ok := meta.HookNames[HookPostRemoteAccountDiscovered]; ok {
if lV, ok := pMap[lua.LString(postRemoteAccountDiscoveredName)].(*lua.LFunction); ok {
holder.postRemoteAccountDiscovered = lV
}
}
return &holder, nil
}

23
plugins/runner.go Normal file
View file

@ -0,0 +1,23 @@
package plugins
import lua "github.com/yuin/gopher-lua"
type Runner struct {
state *lua.LState
inUse bool
}
func NewRunner() (*Runner, error) {
lState, err := NewBlankState()
if err != nil {
return nil, err
}
return &Runner{
lState,
false,
}, nil
}
func (r *Runner) IsInUse() bool {
return r.inUse
}

11
plugins/util.go Normal file
View file

@ -0,0 +1,11 @@
package plugins
import lua "github.com/yuin/gopher-lua"
func lTableToMap(t *lua.LTable) map[lua.LValue]lua.LValue {
m := map[lua.LValue]lua.LValue{}
t.ForEach(func(l1, l2 lua.LValue) {
m[l1] = l2
})
return m
}