Files
navidrome/plugins/manager_loader.go
Deluan Quintão 2471bb9cf6 feat(plugins): add TTL support, batch operations, and hardening to kvstore (#5127)
* feat(plugins): add expires_at column to kvstore schema

* feat(plugins): filter expired keys in kvstore Get, Has, List

* feat(plugins): add periodic cleanup of expired kvstore keys

* feat(plugins): add SetWithTTL, DeleteByPrefix, and GetMany to kvstore

Add three new methods to the KVStore host service:

- SetWithTTL: store key-value pairs with automatic expiration
- DeleteByPrefix: remove all keys matching a prefix in one operation
- GetMany: retrieve multiple values in a single call

All methods include comprehensive unit tests covering edge cases,
expiration behavior, size tracking, and LIKE-special characters.

* feat(plugins): regenerate code and update test plugin for new kvstore methods

Regenerate host function wrappers and PDK bindings for Go, Python,
and Rust. Update the test-kvstore plugin to exercise SetWithTTL,
DeleteByPrefix, and GetMany.

* feat(plugins): add integration tests for new kvstore methods

Add WASM integration tests for SetWithTTL, DeleteByPrefix, and GetMany
operations through the plugin boundary, verifying end-to-end behavior
including TTL expiration, prefix deletion, and batch retrieval.

* fix(plugins): address lint issues in kvstore implementation

Handle tx.Rollback error return and suppress gosec false positive
for parameterized SQL query construction in GetMany.

* fix(plugins): Set clears expires_at when overwriting a TTL'd key

Previously, calling Set() on a key that was stored with SetWithTTL()
would leave the expires_at value intact, causing the key to silently
expire even though Set implies permanent storage.

Also excludes expired keys from currentSize calculation at startup.

* refactor(plugins): simplify kvstore by removing in-memory size cache

Replaced the in-memory currentSize cache (atomic.Int64), periodic cleanup
timer, and mutex with direct database queries for storage accounting.
This eliminates race conditions and cache drift issues at negligible
performance cost for plugin-sized datasets. Also unified Set and
SetWithTTL into a shared setValue method, simplified DeleteByPrefix to
use RowsAffected instead of a transaction, and added an index on
expires_at for efficient expiration filtering.

* feat(plugins): add generic SQLite migration helper and refactor kvstore schema

Add a reusable migrateDB helper that tracks schema versions via SQLite's
PRAGMA user_version and applies pending migrations transactionally. Replace
the ad-hoc createKVStoreSchema function in kvstore with a declarative
migrations slice, making it easy to add future schema changes. Remove the
now-redundant schema migration test since migrateDB has its own test suite
and every kvstore test exercises the migrations implicitly.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): harden kvstore with explicit NULL handling, prefix validation, and cleanup timeout

- Use sql.NullString for expires_at to explicitly send NULL instead of
  relying on datetime('now', '') returning NULL by accident
- Reject empty prefix in DeleteByPrefix to prevent accidental data wipe
- Add 5s timeout context to cleanupExpired on Close
- Replace time.Sleep in unit tests with pre-expired timestamps

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(plugins): use batch processing in GetMany

Process keys in chunks of 200 using slice.CollectChunks to avoid
hitting SQLite's SQLITE_MAX_VARIABLE_NUMBER limit with large key sets.

* feat(plugins): add periodic cleanup goroutine for expired kvstore keys

Use the manager's context to control a background goroutine that purges
expired keys every hour, stopping naturally on shutdown when the context
is cancelled.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-28 23:12:17 -05:00

434 lines
14 KiB
Go

package plugins
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/experimental"
"golang.org/x/sync/errgroup"
)
// serviceContext provides dependencies needed by host service factories.
type serviceContext struct {
pluginName string
manager *Manager
permissions *Permissions
config map[string]string
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
allowedLibraries []int // Library IDs this plugin can access
allLibraries bool // If true, plugin can access all libraries
}
// hostServiceEntry defines a host service for table-driven registration.
type hostServiceEntry struct {
name string
hasPermission func(*Permissions) bool
create func(*serviceContext) ([]extism.HostFunction, io.Closer)
}
// hostServices defines all available host services.
// Adding a new host service only requires adding an entry here.
var hostServices = []hostServiceEntry{
{
name: "Config",
hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newConfigService(ctx.pluginName, ctx.config)
return host.RegisterConfigHostFunctions(service), nil
},
},
{
name: "SubsonicAPI",
hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterSubsonicAPIHostFunctions(service), nil
},
},
{
name: "Scheduler",
hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance())
return host.RegisterSchedulerHostFunctions(service), service
},
},
{
name: "WebSocket",
hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Websocket
service := newWebSocketService(ctx.pluginName, ctx.manager, perm)
return host.RegisterWebSocketHostFunctions(service), service
},
},
{
name: "Artwork",
hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newArtworkService()
return host.RegisterArtworkHostFunctions(service), nil
},
},
{
name: "Cache",
hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newCacheService(ctx.pluginName)
return host.RegisterCacheHostFunctions(service), service
},
},
{
name: "Library",
hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Library
service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries)
return host.RegisterLibraryHostFunctions(service), nil
},
},
{
name: "KVStore",
hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Kvstore
service, err := newKVStoreService(ctx.manager.ctx, ctx.pluginName, perm)
if err != nil {
log.Error("Failed to create KVStore service", "plugin", ctx.pluginName, err)
return nil, nil
}
return host.RegisterKVStoreHostFunctions(service), service
},
},
{
name: "Users",
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterUsersHostFunctions(service), nil
},
},
{
name: "HTTP",
hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
perm := ctx.permissions.Http
service := newHTTPService(ctx.pluginName, perm)
return host.RegisterHTTPHostFunctions(service), nil
},
},
}
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
// This is a lightweight operation used for plugin discovery and change detection.
// Unlike the old implementation, this does NOT compile the wasm - just reads the manifest JSON.
func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) {
if m.stopped.Load() {
return nil, fmt.Errorf("manager is stopped")
}
manifest, err := readManifest(ndpPath)
if err != nil {
return nil, err
}
sha256Hash, err := computeFileSHA256(ndpPath)
if err != nil {
return nil, fmt.Errorf("computing hash: %w", err)
}
return &PluginMetadata{
Manifest: manifest,
SHA256: sha256Hash,
}, nil
}
// loadEnabledPlugins loads all enabled plugins from the database.
func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugins, err := repo.GetAll()
if err != nil {
return fmt.Errorf("reading plugins from DB: %w", err)
}
g := errgroup.Group{}
g.SetLimit(maxPluginLoadConcurrency)
for _, p := range plugins {
if !p.Enabled {
continue
}
plugin := p // Capture for goroutine
g.Go(func() error {
start := time.Now()
log.Debug(ctx, "Loading enabled plugin", "plugin", plugin.ID, "path", plugin.Path)
// Panic recovery
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Panic while loading plugin", "plugin", plugin.ID, "panic", r)
}
}()
if err := m.loadPluginWithConfig(&plugin); err != nil {
// Store error in DB
plugin.LastError = err.Error()
plugin.Enabled = false
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr)
}
log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err)
return nil
}
// Clear any previous error
if plugin.LastError != "" {
plugin.LastError = ""
plugin.UpdatedAt = time.Now()
if putErr := repo.Put(&plugin); putErr != nil {
log.Error(ctx, "Failed to clear plugin error in DB", "plugin", plugin.ID, putErr)
}
}
m.mu.RLock()
loadedPlugin := m.plugins[plugin.ID]
m.mu.RUnlock()
if loadedPlugin != nil {
log.Info(ctx, "Loaded plugin", "plugin", plugin.ID, "manifest", loadedPlugin.manifest.Name,
"capabilities", loadedPlugin.capabilities, "duration", time.Since(start))
}
return nil
})
}
return g.Wait()
}
// loadPluginWithConfig loads a plugin with configuration from DB.
// The p.Path should point to an .ndp package file.
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
ctx := log.NewContext(m.ctx, "plugin", p.ID)
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
// Track this operation
m.loadWg.Add(1)
defer m.loadWg.Done()
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
// Parse config from JSON
pluginConfig, err := parsePluginConfig(p.Config)
if err != nil {
return err
}
// Parse users from JSON
var allowedUsers []string
if p.Users != "" {
if err := json.Unmarshal([]byte(p.Users), &allowedUsers); err != nil {
return fmt.Errorf("parsing plugin users: %w", err)
}
}
// Parse libraries from JSON
var allowedLibraries []int
if p.Libraries != "" {
if err := json.Unmarshal([]byte(p.Libraries), &allowedLibraries); err != nil {
return fmt.Errorf("parsing plugin libraries: %w", err)
}
}
// Open the .ndp package to get manifest and wasm bytes
pkg, err := openPackage(p.Path)
if err != nil {
return fmt.Errorf("opening package: %w", err)
}
// Build extism manifest
pluginManifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmData{Data: pkg.WasmBytes, Name: "main"},
},
Config: pluginConfig,
Timeout: uint64(defaultTimeout.Milliseconds()),
}
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil {
if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 {
pluginManifest.AllowedHosts = hosts
}
}
// Configure filesystem access for library permission
if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Library != nil && pkg.Manifest.Permissions.Library.Filesystem {
adminCtx := adminContext(ctx)
libraries, err := m.ds.Library(adminCtx).GetAll()
if err != nil {
return fmt.Errorf("failed to get libraries for filesystem access: %w", err)
}
allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)
pluginManifest.AllowedPaths = allowedPaths
}
// Build host functions based on permissions from manifest
var hostFunctions []extism.HostFunction
var closers []io.Closer
svcCtx := &serviceContext{
pluginName: p.ID,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
allowedLibraries: allowedLibraries,
allLibraries: p.AllLibraries,
}
for _, entry := range hostServices {
if entry.hasPermission(pkg.Manifest.Permissions) {
funcs, closer := entry.create(svcCtx)
hostFunctions = append(hostFunctions, funcs...)
if closer != nil {
closers = append(closers, closer)
}
}
}
// Compile the plugin with all host functions
runtimeConfig := wazero.NewRuntimeConfig().
WithCompilationCache(m.cache).
WithCloseOnContextDone(true)
// Enable experimental threads if requested in manifest
if pkg.Manifest.HasExperimentalThreads() {
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
log.Debug(ctx, "Enabling experimental threads support")
}
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: runtimeConfig,
EnableHttpResponseHeaders: true,
}
compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions)
if err != nil {
return fmt.Errorf("compiling plugin: %w", err)
}
// Create instance to detect capabilities
instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{})
if err != nil {
compiled.Close(ctx)
return fmt.Errorf("creating instance: %w", err)
}
instance.SetLogger(extismLogger(p.ID))
capabilities := detectCapabilities(instance)
instance.Close(ctx)
// Validate manifest against detected capabilities
if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil {
compiled.Close(ctx)
return fmt.Errorf("manifest validation: %w", err)
}
m.mu.Lock()
m.plugins[p.ID] = &plugin{
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
closers: closers,
metrics: m.metrics,
allowedUserIDs: allowedUsers,
allUsers: p.AllUsers,
}
m.mu.Unlock()
// Call plugin init function
callPluginInit(ctx, m.plugins[p.ID])
return nil
}
// parsePluginConfig parses a JSON config string into a map of string values.
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
func parsePluginConfig(configJSON string) (map[string]string, error) {
if configJSON == "" {
return nil, nil
}
var rawConfig map[string]any
if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil {
return nil, fmt.Errorf("parsing plugin config: %w", err)
}
pluginConfig := make(map[string]string)
for key, value := range rawConfig {
switch v := value.(type) {
case string:
pluginConfig[key] = v
default:
// Serialize non-string values as JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("serializing config value %q: %w", key, err)
}
pluginConfig[key] = string(jsonBytes)
}
}
return pluginConfig, nil
}
// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access.
// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only.
// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included.
func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string {
allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs))
for _, id := range allowedLibraryIDs {
allowedLibrarySet[id] = struct{}{}
}
allowedPaths := make(map[string]string)
for _, lib := range libraries {
_, allowed := allowedLibrarySet[lib.ID]
if allLibraries || allowed {
mountPoint := toPluginMountPoint(int32(lib.ID))
hostPath := lib.Path
if !allowWriteAccess {
hostPath = "ro:" + hostPath
}
allowedPaths[hostPath] = mountPoint
log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath)
}
}
if allowWriteAccess {
log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
} else {
log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries)
}
return allowedPaths
}