Added local network access control flag, plugin verification system, and capabilities declaration to the HLD. Updated implementation plan to remove UserPreference functionality from PoC scope while maintaining security features like local network control and hash verification. Added future extensions section outlining potential plugin types beyond metadata agents.
36 KiB
High-Level Design: Navidrome Plugin System
Table of Contents
- 1. Introduction
- 2. System Architecture
- 3. Technical Design
- 4. Security Considerations
- 5. Development and Deployment
- 6. Implementation Plan
- 7. Future Extensions
1. Introduction
1.1 Purpose
This document describes the high-level design for implementing a plugin system in Navidrome. The plugin system will allow extending Navidrome's functionality without modifying the core codebase, starting with metadata agents as the first plugin type.
1.2 Scope
The initial implementation will focus on:
- Creating a plugin infrastructure based on WebAssembly using knqyf263/go-plugin
- Moving the Last.fm metadata agent to a plugin as proof of concept
- Providing a secure way for plugins to interact with Navidrome's configuration and user data
1.3 Definitions and Acronyms
- Plugin: An extension module loaded at runtime
- WebAssembly/Wasm: A binary instruction format that enables high-performance applications on web pages
- Agent: A component that retrieves metadata from external sources
- Host Function: A function provided by the host application that can be called by plugins
- Plugin Manifest: A JSON file that declares plugin capabilities, permissions, and configuration requirements
2. System Architecture
2.1 Architectural Overview
The plugin system follows a client-server architecture where Navidrome acts as the host (server) and plugins are clients that implement predefined interfaces.
flowchart TB
subgraph Core["Navidrome Core (Host)"]
Manager["Plugin Manager"]
style Manager fill:#3a5e8c,stroke:#66ccff
Bridge["Host Function Bridge"]
style Bridge fill:#3a5e8c,stroke:#66ccff
Interface["Plugin Interface Definitions"]
style Interface fill:#3a5e8c,stroke:#66ccff
HTTP["HTTP Client Service"]
style HTTP fill:#3a5e8c,stroke:#66ccff
PermManager["Permission Manager"]
style PermManager fill:#3a5e8c,stroke:#66ccff
Manager -->|"Loads & manages"| Bridge
Interface ---|"Defines API"| Bridge
Bridge -->|"Provides"| HTTP
Bridge -->|"Checks"| PermManager
end
subgraph Plugins["External Plugins (Clients)"]
LastFM["Last.fm Plugin"]
style LastFM fill:#8c5e3a,stroke:#ffcc66
Spotify["Spotify Plugin"]
style Spotify fill:#8c5e3a,stroke:#ffcc66
Others["Other Plugins"]
style Others fill:#8c5e3a,stroke:#ffcc66
end
Manager -->|"Loads & initializes"| Plugins
Interface -->|"Implemented by"| Plugins
Plugins -->|"Calls host functions via"| Bridge
2.2 Component Description
2.2.1 Plugin Manager
The central component responsible for managing plugins. It handles:
- Discovery and loading of plugins
- Plugin lifecycle management
- Communication between plugins and core components
- Reading plugin manifests and registering capabilities
2.2.2 Host Function Bridge
Provides access to Navidrome functionality for plugins, including:
- Configuration access
- User preferences
- Logging services
- HTTP client services (for external API calls)
2.2.3 Plugin Interface Definitions
Defined using Protocol Buffers, these interfaces describe:
- Methods plugins must implement
- Data structures for communication
- Version information
2.2.4 Agent Plugins
Implementations of metadata agents, starting with:
- Last.fm agent plugin (proof of concept)
- Future plugins for other metadata sources
2.2.5 Permission Manager
Component that:
- Validates plugin manifests against administrator-defined security policies
- Controls which host functions each plugin can access
- Enforces URL-specific HTTP access restrictions
- Manages data access permissions (configuration, user preferences)
- Provides runtime security checks during plugin execution
2.3 Data Flow
The following diagram illustrates the interaction between components in two key phases: plugin initialization and metadata request handling:
sequenceDiagram
participant PM as Plugin Manager
participant HB as Host Bridge
participant PermMgr as Permission Manager
participant Plugin as Plugin (e.g., Last.fm)
participant External as External API
Note over PM,Plugin: Plugin Initialization Phase
PM->>PM: Read plugin manifest
PM->>PermMgr: Verify plugin permissions
PermMgr->>PM: Confirm permissions
PM->>PM: Prepare plugin config
PM->>Plugin: Load plugin
PM->>Plugin: Init(config)
Plugin->>Plugin: Process configuration
PM->>Plugin: Register capabilities
Note over PM,External: Metadata Request Phase (Runtime)
PM->>Plugin: Request artist/album metadata
Plugin->>HB: Request HTTP call to external API
HB->>PermMgr: Verify HTTP permission
PermMgr->>HB: Grant permission (if method allowed)
HB->>External: Forward HTTP request
External->>HB: Return API response data
HB->>Plugin: Forward API response
Plugin->>PM: Return processed metadata
During initialization, the Plugin Manager reads the plugin manifest, verifies permissions, prepares the appropriate configuration, and passes it directly to the plugin's Init() method. This ensures the plugin has all necessary configuration before any operations are performed, and eliminates unnecessary RPC calls.
At runtime, plugins handle metadata requests by making external API calls as needed through the Host Bridge, which still performs permission checks for each request.
3. Technical Design
3.1 Protocol Buffer Definitions
The plugin system will define interfaces using Protocol Buffers:
// plugins/proto/agent.proto
syntax = "proto3";
package proto;
option go_package = "github.com/navidrome/navidrome/plugins/proto";
// go:plugin type=plugin version=1
service AgentPlugin {
// GetArtistMBID retrieves the MusicBrainz ID for an artist
rpc GetArtistMBID(GetArtistMBIDRequest) returns (GetArtistMBIDResponse) {}
// GetArtistURL retrieves the URL for an artist
rpc GetArtistURL(GetArtistURLRequest) returns (GetArtistURLResponse) {}
// GetArtistBiography retrieves the biography for an artist
rpc GetArtistBiography(GetArtistBiographyRequest) returns (GetArtistBiographyResponse) {}
// GetSimilarArtists retrieves similar artists
rpc GetSimilarArtists(GetSimilarArtistsRequest) returns (GetSimilarArtistsResponse) {}
// GetArtistImages retrieves artist images
rpc GetArtistImages(GetArtistImagesRequest) returns (GetArtistImagesResponse) {}
// GetArtistTopSongs retrieves top songs for an artist
rpc GetArtistTopSongs(GetArtistTopSongsRequest) returns (GetArtistTopSongsResponse) {}
// GetAlbumInfo retrieves album information
rpc GetAlbumInfo(GetAlbumInfoRequest) returns (GetAlbumInfoResponse) {}
// GetAgentName returns the name of the agent
rpc GetAgentName(GetAgentNameRequest) returns (GetAgentNameResponse) {}
}
// go:plugin type=host
service HostFunctions {
// GetUserPreference retrieves a user preference
rpc GetUserPreference(GetUserPreferenceRequest) returns (GetUserPreferenceResponse) {}
// SetUserPreference sets a user preference
rpc SetUserPreference(SetUserPreferenceRequest) returns (SetUserPreferenceResponse) {}
// GetConfig retrieves the value of a configuration setting
rpc GetConfig(GetConfigRequest) returns (GetConfigResponse) {}
// Log allows plugins to log messages
rpc Log(LogRequest) returns (LogResponse) {}
// Generic HTTP function for external API calls
rpc HttpDo(HttpDoRequest) returns (HttpDoResponse) {}
}
// HTTP message definitions
message HttpDoRequest {
// HTTP method (GET, POST, PUT, DELETE, etc.)
string method = 1;
// URL to make the request to
string url = 2;
// HTTP headers
map<string, string> headers = 3;
// Request body (for POST, PUT, etc.)
bytes body = 4;
// Content type of the body
string content_type = 5;
// Timeout in seconds
int32 timeout_seconds = 6;
}
message HttpDoResponse {
// HTTP status code
int32 status_code = 1;
// Response headers
map<string, string> headers = 2;
// Response body
bytes body = 3;
// Error message if request failed
string error = 4;
}
3.2 Plugin Manifest
Each plugin must include a manifest file (manifest.json
) that declares its capabilities and required permissions:
{
"name": "lastfm",
"version": "1.0.0",
"description": "Last.fm metadata agent",
"author": "Navidrome Team",
"pluginType": "agent",
"requiredPermissions": {
"hostFunctions": ["HttpDo", "GetConfig", "Log", "GetUserPreference"],
"allowedUrls": {
"https://api.last.fm": ["GET", "POST"], // Specific URL with specific methods
"https://ws.audioscrobbler.com": ["*"], // Any method on specific domain
"https://*.last.fm": ["GET"], // GET requests to any last.fm subdomain
"*": ["GET"] // GET requests to any URL (use with caution)
},
"allowRedirects": true,
"allowLocalNetwork": false // New: default to false, must be explicitly enabled
},
"capabilities": {
"agent": {
"GetArtistMBID": true,
"GetArtistURL": true,
"GetArtistBiography": true,
"GetSimilarArtists": true,
"GetArtistImages": true,
"GetArtistTopSongs": true,
"GetAlbumInfo": true
}
},
"configurationOptions": [
{ "name": "ApiKey", "required": true, "description": "Last.fm API key" },
{
"name": "Secret",
"required": true,
"description": "Last.fm API secret",
"sensitive": true
}
]
}
The manifest structure includes:
- Basic plugin metadata (name, version, description)
- Required permissions for host functions and HTTP methods
- Specific allowed URLs with permitted HTTP methods for each, supporting wildcards:
- Exact URLs with specific methods
- Domain-specific wildcards with
"*"
for any method - Domain pattern wildcards (e.g.,
"https://*.domain.com"
) - Full wildcard
"*": ["*"]
for unrestricted access (should be used with caution)
- Whether redirects are allowed to be followed
- A flag to allow local network access (
allowLocalNetwork
) which is disabled by default for security - Capabilities the plugin supports, allowing it to declare which functions it implements
- Configuration options the plugin needs to function
3.3 Plugin Manager Implementation
The Plugin Manager will be responsible for loading and managing plugins:
// plugins/manager.go
package plugins
type Manager struct {
ds model.DataStore
pluginsDir string
loadedPlugins map[string]interface{}
agentPlugins map[string]*AgentPlugin
permManager *PermissionManager
lock sync.RWMutex
}
func (m *Manager) Initialize(ctx context.Context) error {
// Initialize plugins directory and scan for available plugins
// Read plugin manifests
// Verify permissions with permission manager
// Load and initialize each plugin with its configuration
// Register plugin capabilities
}
func (m *Manager) LoadPlugin(manifest *PluginManifest) error {
// Check if plugin is enabled in configuration
// Load plugin from WASM file
// Prepare plugin configuration
config := m.preparePluginConfig(manifest.Name)
// Initialize plugin with configuration
err := plugin.Init(config)
if err != nil {
return fmt.Errorf("failed to initialize plugin: %w", err)
}
// Register plugin capabilities
m.registerPluginCapabilities(manifest.Name, plugin)
return nil
}
func (m *Manager) GetAgentPlugin(name string) agents.Interface {
// Return agent plugin by name if available
}
func (m *Manager) LoadPluginManifest(path string) (*PluginManifest, error) {
// Read and parse manifest.json from plugin directory
}
func (m *Manager) preparePluginConfig(pluginName string) map[string]interface{} {
// Get plugin-specific configuration from Navidrome config
// Filter out any sensitive fields plugin shouldn't access
// Return prepared configuration map
}
3.4 Permission Manager Implementation
// plugins/permission_manager.go
package plugins
type PermissionManager struct {
config *conf.Configuration
pluginSettings map[string]conf.PluginOptions
}
func (p *PermissionManager) IsHostFunctionAllowed(pluginName, functionName string) bool {
// Check if function is allowed for this plugin
}
func (p *PermissionManager) IsHttpMethodAllowed(pluginName, method string) bool {
// Check if HTTP method is allowed for this plugin
}
func (p *PermissionManager) GetPluginConfig(pluginName string) map[string]interface{} {
// Return plugin-specific configuration
}
3.5 Host Functions Implementation
Host functions provide plugins with access to Navidrome services. Even though configuration is passed during initialization, plugins might still need to access some configuration values at runtime:
// plugins/host_functions.go
package plugins
type HostFunctions struct {
ds model.DataStore
httpClient *http.Client
permManager *PermissionManager
pluginContext *PluginContext // Holds current plugin name
}
func (h *HostFunctions) GetUserPreference(ctx context.Context, req proto.GetUserPreferenceRequest) (proto.GetUserPreferenceResponse, error) {
// Check permission
if !h.permManager.IsHostFunctionAllowed(h.pluginContext.Name, "GetUserPreference") {
return proto.GetUserPreferenceResponse{}, errors.New("permission denied")
}
// Scope the preference key with plugin name to prevent collisions
scopedKey := fmt.Sprintf("%s:%s", h.pluginContext.Name, req.Key)
// Retrieve user preference from datastore using scoped key
}
func (h *HostFunctions) SetUserPreference(ctx context.Context, req proto.SetUserPreferenceRequest) (proto.SetUserPreferenceResponse, error) {
// Check permission
if !h.permManager.IsHostFunctionAllowed(h.pluginContext.Name, "SetUserPreference") {
return proto.SetUserPreferenceResponse{}, errors.New("permission denied")
}
// Scope the preference key with plugin name to prevent collisions
scopedKey := fmt.Sprintf("%s:%s", h.pluginContext.Name, req.Key)
// Set user preference in datastore using scoped key
}
func (h *HostFunctions) GetConfig(ctx context.Context, req proto.GetConfigRequest) (proto.GetConfigResponse, error) {
// Check permission
if !h.permManager.IsHostFunctionAllowed(h.pluginContext.Name, "GetConfig") {
return proto.GetConfigResponse{}, errors.New("permission denied")
}
// Used for dynamic configuration access during runtime, not for initial setup
// Retrieve configuration safely, respecting permission boundaries
}
func (h *HostFunctions) HttpDo(ctx context.Context, req proto.HttpDoRequest) (proto.HttpDoResponse, error) {
// Check permission for HttpDo function
if !h.permManager.IsHostFunctionAllowed(h.pluginContext.Name, "HttpDo") {
return proto.HttpDoResponse{}, errors.New("permission denied")
}
// Extract the base URL for permission checking
parsedURL, err := url.Parse(req.Url)
if err != nil {
return proto.HttpDoResponse{}, fmt.Errorf("invalid URL: %v", err)
}
// Check if requesting access to local network and if it's allowed
if isInternalAddress(parsedURL.Host) && !h.permManager.IsLocalNetworkAllowed(h.pluginContext.Name) {
return proto.HttpDoResponse{}, errors.New("access to internal network addresses is forbidden")
}
// Check if the URL is allowed for this plugin with the specific method
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
if !h.permManager.IsUrlAllowed(h.pluginContext.Name, baseURL, req.Method) {
return proto.HttpDoResponse{}, fmt.Errorf("URL not allowed with method %s: %s", req.Method, baseURL)
}
// Configure redirect policy based on permissions
client := *h.httpClient // Create a copy of the client to modify
if !h.permManager.AreRedirectsAllowed(h.pluginContext.Name) {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Prevent following redirects
}
}
// Create and send HTTP request based on the method and parameters provided
// Return the response or error
}
// Helper function to detect internal network addresses
func isInternalAddress(host string) bool {
// Remove port from host if present
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
// Check if IP address
ip := net.ParseIP(host)
if ip != nil {
// Block private, loopback, and link-local addresses
return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
}
// For hostnames, try to resolve and check IPs
ips, err := net.LookupIP(host)
if err != nil {
// If we can't resolve, default to allowing it
return false
}
for _, ip := range ips {
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
}
return false
}
// IsUrlAllowed checks if a URL and method are allowed for a plugin
func (p *PermissionManager) IsUrlAllowed(pluginName, requestURL, method string) bool {
pluginSettings, ok := p.pluginSettings[pluginName]
if !ok {
return false
}
// Check for exact URL match first
if methods, ok := pluginSettings.Limits.AllowedUrls[requestURL]; ok {
return isMethodAllowed(methods, method)
}
// Check for wildcard domain matches
for pattern, methods := range pluginSettings.Limits.AllowedUrls {
if patternMatchesURL(pattern, requestURL) && isMethodAllowed(methods, method) {
return true
}
}
return false
}
// patternMatchesURL checks if a URL pattern matches a given URL
func patternMatchesURL(pattern, url string) bool {
// Handle global wildcard
if pattern == "*" {
return true
}
// Handle domain wildcards like "https://*.example.com"
if strings.Contains(pattern, "*") {
regexp := strings.Replace(pattern, ".", "\\.", -1)
regexp = strings.Replace(regexp, "*", ".*", -1)
regexp = "^" + regexp + "$"
match, err := regexp.MatchString(regexp, url)
return err == nil && match
}
return false
}
// isMethodAllowed checks if a method is allowed in a list of methods
func isMethodAllowed(allowedMethods []string, method string) bool {
for _, m := range allowedMethods {
if m == method || m == "*" {
return true
}
}
return false
}
3.6 Configuration Structure
The configuration system will be enhanced to support per-plugin settings:
// Global plugin settings
type GlobalPluginsOptions struct {
Enabled bool
Directory string
DefaultLimits PluginLimits
}
// Limits and permissions that can be applied globally or per-plugin
type PluginLimits struct {
AllowedHostFuncs []string
HttpTimeoutSeconds int
MaxHttpBodySizeMB int
AllowedUrls map[string][]string // Map of URLs to allowed methods
AllowRedirects bool
RateLimits map[string]int // e.g., "requests_per_minute": 60
}
// Plugin-specific options
type PluginOptions struct {
Enabled bool
Limits PluginLimits
Config map[string]interface{} // Custom plugin configuration
}
// Updated configuration structure
type ServerConfig struct {
// ...existing fields...
// Global plugin settings
Plugins GlobalPluginsOptions
// Per-plugin settings
PluginSettings map[string]PluginOptions
}
Example configuration in navidrome.toml
:
[Plugins]
Enabled = true
Directory = "${DataFolder}/plugins"
[Plugins.DefaultLimits]
HttpTimeoutSeconds = 30
MaxHttpBodySizeMB = 10
AllowRedirects = false
[PluginSettings.lastfm]
Enabled = true
[PluginSettings.lastfm.Limits]
AllowedHostFuncs = ["HttpDo", "GetConfig", "Log", "GetUserPreference"]
AllowRedirects = true
[PluginSettings.lastfm.Limits.AllowedUrls]
"https://api.last.fm" = ["GET", "POST"] # Specific URL with specific methods
"https://ws.audioscrobbler.com" = ["*"] # Any method on specific domain
"https://*.last.fm" = ["GET"] # GET requests to any last.fm subdomain
[PluginSettings.lastfm.Config]
ApiKey = "your_api_key_here"
Secret = "your_secret_here"
[PluginSettings.spotify]
Enabled = true
[PluginSettings.spotify.Limits]
AllowedHostFuncs = ["HttpDo", "Log"]
AllowRedirects = true
[PluginSettings.spotify.Limits.AllowedUrls]
"https://api.spotify.com" = ["GET"] # Specific URL with specific method
[PluginSettings.spotify.Config]
ClientId = "your_client_id"
ClientSecret = "your_client_secret"
# Development plugin with unrestricted access - USE WITH CAUTION
[PluginSettings.devplugin]
Enabled = true
[PluginSettings.devplugin.Limits]
AllowedHostFuncs = ["HttpDo", "GetConfig", "Log", "GetUserPreference"]
AllowRedirects = true
[PluginSettings.devplugin.Limits.AllowedUrls]
"*" = ["*"] # Unrestricted access to any URL with any method
3.7 Integration with Existing Agent System
The plugin system will integrate with the existing agent architecture by adapting loaded plugins to conform to the current agent interface model. This approach allows for immediate plugin functionality without requiring major refactoring of the core codebase.
3.7.1 Plugin to Agent Adaptation
When an agent plugin is loaded, the Plugin Manager will create an adapter that implements the appropriate agent interfaces, then register it with the existing agent system:
// Example adapter for agent plugins
type PluginAgentAdapter struct {
plugin *AgentPlugin
pluginName string
ds model.DataStore
}
func (a *PluginAgentAdapter) AgentName() string {
return a.pluginName
}
// Implement the agents.Interface interface
func (a *PluginAgentAdapter) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
// Convert to protobuf request
req := &proto.GetSimilarArtistsRequest{
Id: id,
Name: name,
Mbid: mbid,
Limit: int32(limit),
}
// Call plugin
resp, err := a.plugin.GetSimilarArtists(ctx, req)
if err != nil {
return nil, err
}
// Convert protobuf response to agent interface
artists := make([]agents.Artist, len(resp.Artists))
for i, artist := range resp.Artists {
artists[i] = agents.Artist{
Name: artist.Name,
Mbid: artist.Mbid,
}
}
return artists, nil
}
// Implement other interfaces (ArtistMBIDRetriever, ArtistURLRetriever, etc.) similarly
The following diagram illustrates how the plugin system integrates with the existing agent architecture:
flowchart TD
classDef core fill:#3a5e8c,stroke:#66ccff,color:#ffffff
classDef plugin fill:#8c5e3a,stroke:#ffcc66,color:#ffffff
classDef adapter fill:#5e8c3a,stroke:#66ff66,color:#ffffff
subgraph PluginSystem["Plugin System"]
PluginMgr["Plugin Manager"]:::core
Plugin1["Last.fm Plugin"]:::plugin
Plugin2["Spotify Plugin"]:::plugin
Plugin3["Custom Plugin"]:::plugin
Adapter1["Last.fm
Plugin Adapter"]:::adapter
Adapter2["Spotify
Plugin Adapter"]:::adapter
Adapter3["Custom
Plugin Adapter"]:::adapter
PluginMgr -->|"Loads"| Plugin1
PluginMgr -->|"Loads"| Plugin2
PluginMgr -->|"Loads"| Plugin3
Plugin1 -->|"Wrapped by"| Adapter1
Plugin2 -->|"Wrapped by"| Adapter2
Plugin3 -->|"Wrapped by"| Adapter3
end
subgraph ExistingSystem["Existing Agent System"]
AgentRegistry["Agent Registry
(Map variable)"]:::core
MetaAgent["Meta Agent
(agents.Agents)"]:::core
BuiltIn1["Built-in Agent 1"]:::core
BuiltIn2["Built-in Agent 2"]:::core
AgentRegistry -->|"Creates"| MetaAgent
AgentRegistry -->|"Registers"| BuiltIn1
AgentRegistry -->|"Registers"| BuiltIn2
end
Adapter1 -->|"Registered with"| AgentRegistry
Adapter2 -->|"Registered with"| AgentRegistry
Adapter3 -->|"Registered with"| AgentRegistry
MetaAgent -->|"Calls in priority order"| BuiltIn1
MetaAgent -->|"Calls in priority order"| BuiltIn2
MetaAgent -->|"Calls in priority order"| Adapter1
MetaAgent -->|"Calls in priority order"| Adapter2
MetaAgent -->|"Calls in priority order"| Adapter3
External["External
Metadata
Requests"]:::core
External -->|"GetSimilarArtists
GetArtistBiography
etc."| MetaAgent
The diagram shows how:
- The Plugin Manager loads WebAssembly plugins
- Each plugin is wrapped by an adapter that implements the agent interfaces
- Adapters are registered with the existing Agent Registry
- The Meta Agent (agents.Agents) calls all agents, including plugin adapters, in priority order
- External metadata requests flow through the Meta Agent to all registered agents
3.7.2 Plugin Registration
The Plugin Manager will register the plugin adapter with the existing agent system:
func (m *Manager) registerAgentPlugin(plugin *AgentPlugin, manifest *PluginManifest) {
// Create adapter
adapter := &PluginAgentAdapter{
plugin: plugin,
pluginName: manifest.Name,
ds: m.ds,
}
// Register with the agent system
agents.Register(manifest.Name, func(ds model.DataStore) agents.Interface {
return adapter
})
// Store in plugin manager for direct access if needed
m.agentPlugins[manifest.Name] = plugin
}
3.7.3 Agent Prioritization
The existing configuration system for agent ordering will be maintained, allowing administrators to specify the priority of both built-in agents and plugin agents:
# Example navidrome.toml configuration
[Server]
# Comma-separated list of agent names in order of preference
Agents = "spotify,lastfm,custom-plugin"
3.7.4 Future Evolution
While the initial implementation will adapt plugins to the existing agent architecture, a future refactoring may introduce a more plugin-oriented Registry-Based Approach. This would involve:
- Creating a centralized registry for metadata providers
- Explicit capability declaration for each provider
- More granular configuration of provider priorities per capability
- A common interface for both built-in and plugin providers
This future evolution would provide better organization and extension capabilities while maintaining backward compatibility through the transition period.
4. Security Considerations
4.1 Plugin Sandbox
Plugins will run in a WebAssembly sandbox with limited capabilities:
- No direct file system access outside of designated paths
- No network access except through provided host functions
- No process spawning capabilities
4.2 Granular Permission Control
- Each plugin declares required permissions in its manifest
- Admin must explicitly configure and grant permissions
- Permissions are enforced at the host function level
- Different plugins can have different permission sets
4.3 Configuration Access Control
- Only a specific subset of configuration values will be exposed to plugins
- Configuration values will be provided through the plugin-specific settings
- Sensitive values like API keys can be limited to specific plugins
4.4 User Data Protection
- Plugins can only access user data through controlled interfaces
- Authentication and authorization are handled by the host
- Each plugin can be restricted from accessing user data if not needed
4.5 HTTP Security
- All HTTP requests from plugins are mediated through the unified HttpDo interface
- URLs are restricted to an explicit allowlist with specific HTTP methods allowed per URL
- Internal network addresses (private IP ranges, localhost) are explicitly blocked by default
- Plugins that need local network access must explicitly request it via the
allowLocalNetwork
flag in their manifest - Redirects require explicit permission to prevent URL allowlist bypass
- URL validation prevents access to internal/restricted networks
- Rate limiting prevents abuse of external services
- Response size limits prevent memory exhaustion
4.6 Plugin Verification
The Plugin Manager verifies the integrity of plugins upon loading:
func (m *Manager) VerifyPluginIntegrity(pluginPath string, expectedHash string) (bool, error) {
// Calculate SHA-256 hash of plugin file
fileData, err := ioutil.ReadFile(pluginPath)
if err != nil {
return false, fmt.Errorf("failed to read plugin file: %w", err)
}
actualHash := sha256.Sum256(fileData)
actualHashStr := hex.EncodeToString(actualHash[:])
return actualHashStr == expectedHash, nil
}
During plugin installation, the hash is calculated and stored locally. When the plugin is loaded, its integrity is verified by comparing the current hash with the stored hash. This ensures that the plugin binary has not been modified since installation.
Future versions may integrate with a plugin registry service for automated verification and updates.
5. Development and Deployment
5.1 Plugin Development Workflow
graph LR
A[Define Interface] --> B[Create Manifest]
B --> C[Implement Plugin]
C --> D[Compile to WASM]
D --> E[Test Plugin]
E --> F[Package Plugin]
F --> G[Distribute Plugin]
5.2 CLI Commands for Plugin Management
Navidrome will include CLI commands for plugin management:
navidrome plugin list # List all installed plugins
navidrome plugin info [name] # Show plugin information and manifest
navidrome plugin config-template [name] # Generate config template for plugin
navidrome plugin install [file] # Install a plugin from a .wasm file
navidrome plugin remove [name] # Remove an installed plugin
navidrome plugin dev [folder_path] # Create symlink to development folder
navidrome plugin refresh [name] # Reload plugin without restart
5.3 Plugin Installation Flow
- Admin installs plugin file in the plugins directory
- Navidrome detects new plugin on startup
- Navidrome reads the plugin manifest and logs requirements
- Admin runs
navidrome plugin info [name]
to view details - Admin runs
navidrome plugin config-template [name]
to get configuration template - Admin adds configuration to
navidrome.toml
- Navidrome loads plugin on next restart
5.4 Plugin Distribution and Packaging
Plugins will be distributed as .ndp
(Navidrome Plugin) files, which are ZIP archives containing:
plugin.wasm
- The WebAssembly binarymanifest.json
- The plugin manifest- Optional
README.md
- Documentation
This format simplifies distribution and installation while keeping all plugin files together.
Creating a plugin package:
# Create plugin package
zip myplugin.zip plugin.wasm manifest.json README.md
mv myplugin.zip myplugin.ndp
Distribution channels include:
- GitHub releases
- Navidrome plugin repository
- OCI registries
5.5 Plugin Development Workflow
For plugin developers, Navidrome provides additional commands to streamline the development process:
navidrome plugin dev [folder_path] # Create symlink to development folder
navidrome plugin refresh [name] # Reload plugin without restart
The plugin dev
command creates a symlink to the development folder, allowing developers to work on plugin files directly without packaging. The folder should contain at minimum:
my-plugin/
├── plugin.wasm # Compiled binary
├── manifest.json # Plugin manifest
The plugin refresh
command reloads a specific plugin without requiring a Navidrome restart, which enables rapid testing and iteration during development.
A typical development workflow:
- Create plugin interface and manifest
- Run
navidrome plugin dev ./my-plugin
to link development folder - Implement and compile plugin to WebAssembly
- Run
navidrome plugin refresh my-plugin
to test changes - Repeat steps 3-4 until implementation is complete
- Package as
.ndp
file for distribution
5.6 Plugin Directory Structure
Plugins are stored in a dedicated plugins directory, which by default is a subdirectory of Navidrome's data folder:
<DataFolder>/plugins/
├── lastfm/ # Each plugin has its own subdirectory
│ ├── plugin.wasm # The WebAssembly binary
│ ├── manifest.json # The plugin manifest
│ └── README.md # Optional documentation
├── spotify/
│ ├── plugin.wasm
│ ├── manifest.json
│ └── README.md
└── other-plugin/
├── plugin.wasm
├── manifest.json
└── README.md
The plugins directory location can be configured in navidrome.toml
:
[Plugins]
Enabled = true
Directory = "${DataFolder}/plugins" # Default, can be overridden
When Navidrome starts, it scans this directory for subdirectories containing WASM files and manifests, loads the plugins, and registers them with the appropriate subsystems based on their declared capabilities.
For development purposes, the plugin dev
command can create a symlink to a development directory outside of the standard plugins directory, allowing developers to work on plugin files without having to manually copy them after each change.
6. Implementation Plan
The implementation plan is detailed in a separate document: plugins-implementation-plan.md
7. Future Extensions
The initial implementation of the plugin system focuses on metadata agents. Future versions may extend support to additional plugin types:
- ExternalMetadata Plugins: Provide lyrics, extended artist and album information
- Scrobbler Plugins: Send play history to various music services
- Playlist Generator Plugins: Create smart playlists based on various criteria
- Storage Plugins: Support for cloud storage providers (S3, etc.)
- External Player Plugins: Integration with Sonos, DLNA, and the Jukebox feature
- Podcast Plugins: Implementation of Podcast support
Additional planned enhancements:
- Enhanced URL pattern matching with path support
- Standardized UI for plugin configuration
- Plugin testing harness for developers
- Plugin registry and update mechanism
- Support for additional protocols beyond HTTP