fix: второй стабилизационный проход Lua plugin lifecycle

1. Enabled/Active state separation:
   - Enable() sets Enabled=true (persisted in config), does NOT create runtime
   - ActivatePlugin() checks Enabled && !Active, creates VM + scheduler
   - DeactivatePlugin() stops runtime, keeps Enabled=true
   - InitRuntimes() iterates Enabled plugins, sets Active=true after creation
   - SyncConfig() restores Enabled from config, does NOT touch Active

2. ActivatePlugin: добавлен vm.SetServices(m.Services)

3. Discover: атомарная замена списка (newPlugins slice), нет дублирования

4. CallPluginFunction: thread-safe через LuaVM.CallFunction (vm.mu + callWithTimeout)

5. Uninstall активного плагина: полная деактивация (StopScheduler → on_shutdown → CloseVM → Active=false)

6. GetPluginPanelHTML: валидация panel path (no absolute, no .., must be .html, must be within plugin dir)

7. PluginPage: убран hardcoded 'calendar-plugin', используется funcPrefix из pluginName

Тесты:
- security_test.go: +8 тестов (FullLifecycle, ActivatePlugin_Services, Discover_Idempotent,
  ReloadPlugins_NoDuplicates, CallPluginFunction_Timeout, Uninstall_ActivePlugin,
  GetPluginPanelHTML_PathTraversal, FullLifecycle_EndToEnd)
- manager_test.go: обновлены тесты под новую семантику Enabled/Active
This commit is contained in:
2026-06-07 20:49:43 +08:00
parent 4df83cd361
commit d83c8c80e1
10 changed files with 654 additions and 107 deletions
+40 -50
View File
@@ -105,22 +105,28 @@ func (a *App) ListPlugins() []PluginDTO {
}
// SetPluginEnabled persists the enabled/disabled state and applies it to the runtime.
// Returns error if the plugin is not installed but has install lifecycle.
// Enable: marks plugin as enabled, then activates runtime (VM + scheduler).
// Disable: deactivates runtime, then marks plugin as disabled.
func (a *App) SetPluginEnabled(name string, enabled bool) error {
if a.plugins == nil {
return fmt.Errorf("plugin manager not ready")
}
if enabled {
// Enable first (sets Enabled=true), then activate runtime
if err := a.plugins.Enable(name); err != nil {
return err
}
a.plugins.ActivatePlugin(name)
} else {
// Deactivate runtime first, then disable
a.plugins.DeactivatePlugin(name)
if err := a.plugins.Disable(name); err != nil {
return err
}
}
// Persist enabled state in config
appCfg, _ := config.LoadAppConfig()
if appCfg == nil {
appCfg = config.DefaultAppConfig()
@@ -143,16 +149,12 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error {
return fmt.Errorf("save config: %w", err)
}
if enabled {
a.plugins.ActivatePlugin(name)
} else {
a.plugins.DeactivatePlugin(name)
}
return nil
}
// GetPluginPanelHTML returns the HTML panel content for a plugin.
// Validates that the panel path is safe: no absolute paths, no .. traversal,
// must be within the plugin directory, and must end with .html.
func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
if a.plugins == nil {
return "", fmt.Errorf("plugin manager not ready")
@@ -164,10 +166,36 @@ func (a *App) GetPluginPanelHTML(pluginName string) (string, error) {
if p.Meta.Panel == "" {
return "", nil
}
panelPath := filepath.Join(p.Dir, p.Meta.Panel)
data, err := os.ReadFile(panelPath)
// Validate panel path: must be relative, no .., within plugin dir, .html only
panel := p.Meta.Panel
if filepath.IsAbs(panel) {
return "", fmt.Errorf("panel path %q must be relative", panel)
}
if strings.Contains(panel, "..") {
return "", fmt.Errorf("panel path %q must not contain ..", panel)
}
if !strings.HasSuffix(strings.ToLower(panel), ".html") {
return "", fmt.Errorf("panel path %q must end with .html", panel)
}
// Resolve and verify the path is within the plugin directory
panelPath := filepath.Join(p.Dir, panel)
absPanel, err := filepath.Abs(panelPath)
if err != nil {
return "", fmt.Errorf("read panel %s: %w", p.Meta.Panel, err)
return "", fmt.Errorf("resolve panel path: %w", err)
}
absDir, err := filepath.Abs(p.Dir)
if err != nil {
return "", fmt.Errorf("resolve plugin dir: %w", err)
}
if !strings.HasPrefix(absPanel, absDir+string(filepath.Separator)) {
return "", fmt.Errorf("panel path %q escapes plugin directory", panel)
}
data, err := os.ReadFile(absPanel)
if err != nil {
return "", fmt.Errorf("read panel %s: %w", panel, err)
}
return string(data), nil
}
@@ -241,52 +269,14 @@ func (a *App) CallPluginFunction(pluginName, funcName string, paramsJSON string)
continue
}
// Resolve the function via _G to avoid string-based code generation
var fn lua.LValue
if len(segments) == 1 {
fn = vm.LState().GetGlobal(segments[0])
} else {
// Walk the dotted path: _G[seg1][seg2]...
tbl := vm.LState().GetGlobal(segments[0])
for i := 1; i < len(segments); i++ {
if t, ok := tbl.(*lua.LTable); ok {
tbl = t.RawGetString(segments[i])
} else {
tbl = lua.LNil
break
}
}
fn = tbl
}
if fn == lua.LNil {
return "", fmt.Errorf("function %q not found in plugin %q", funcName, pluginName)
}
if _, ok := fn.(*lua.LFunction); !ok {
return "", fmt.Errorf("%q is not a function in plugin %q", funcName, pluginName)
}
// Parse params into Lua value
luaArg, err := parseParamsToLua(vm, paramsJSON)
if err != nil {
return "", fmt.Errorf("parse params: %w", err)
}
// Call the function directly via PCall (no string-based code generation)
vm.LState().Push(fn)
if luaArg != nil {
vm.LState().Push(luaArg)
}
nargs := 0
if luaArg != nil {
nargs = 1
}
if err := vm.LState().PCall(nargs, 1, nil); err != nil {
return "", fmt.Errorf("call %s: %w", funcName, err)
}
ret := vm.LState().Get(-1)
vm.LState().Pop(1)
return ret.String(), nil
// Call via thread-safe, timeout-safe LuaVM.CallFunction
return vm.CallFunction(segments, luaArg)
}
return "", fmt.Errorf("plugin %q not active or not found", pluginName)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -19,7 +19,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-9sLWbwBV.js"></script>
<script type="module" crossorigin src="/assets/main-8qmy5tDO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Cjkp2F09.css">
</head>
<body>