Files
Charon/backend/internal/services/plugin_loader.go
GitHub Actions b86aa3921b feat(dns): add custom DNS provider plugin system
- Add plugin interface with lifecycle hooks (Init/Cleanup)
- Implement thread-safe provider registry
- Add plugin loader with SHA-256 signature verification
- Migrate 10 built-in providers to registry pattern
- Add multi-credential support to plugin interface
- Create plugin management UI with enable/disable controls
- Add dynamic credential fields based on provider metadata
- Include PowerDNS example plugin
- Add comprehensive user & developer documentation
- Fix frontend test hang (33min → 1.5min, 22x faster)

Platform: Linux/macOS only (Go plugin limitation)
Security: Signature verification, directory permission checks

Backend coverage: 85.1%
Frontend coverage: 85.31%

Closes: DNS Challenge Future Features - Phase 5
2026-01-07 02:54:01 +00:00

347 lines
9.8 KiB
Go

package services
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"plugin"
"runtime"
"strings"
"sync"
"time"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
"gorm.io/gorm"
)
// PluginLoaderService manages loading and unloading of DNS provider plugins.
type PluginLoaderService struct {
pluginDir string
allowedSigs map[string]string // plugin name -> expected signature
loadedPlugins map[string]string // plugin type -> file path
db *gorm.DB
mu sync.RWMutex
}
// NewPluginLoaderService creates a new plugin loader.
func NewPluginLoaderService(db *gorm.DB, pluginDir string, allowedSignatures map[string]string) *PluginLoaderService {
return &PluginLoaderService{
pluginDir: pluginDir,
allowedSigs: allowedSignatures,
loadedPlugins: make(map[string]string),
db: db,
}
}
// LoadAllPlugins loads all .so files from the plugin directory.
func (s *PluginLoaderService) LoadAllPlugins() error {
// Check if plugins are supported on this platform
if runtime.GOOS == "windows" {
logger.Log().Warn("Go plugins are not supported on Windows - only built-in providers available")
return nil
}
if s.pluginDir == "" {
logger.Log().Info("Plugin directory not configured, skipping plugin loading")
return nil
}
// Check if directory exists
entries, err := os.ReadDir(s.pluginDir)
if err != nil {
if os.IsNotExist(err) {
logger.Log().Info("Plugin directory does not exist, skipping plugin loading")
return nil
}
return fmt.Errorf("failed to read plugin directory: %w", err)
}
// Verify directory permissions (security requirement)
if err := s.verifyDirectoryPermissions(s.pluginDir); err != nil {
return fmt.Errorf("plugin directory has insecure permissions: %w", err)
}
loadedCount := 0
failedCount := 0
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".so") {
continue
}
pluginPath := filepath.Join(s.pluginDir, entry.Name())
if err := s.LoadPlugin(pluginPath); err != nil {
logger.Log().WithError(err).Warnf("Failed to load plugin: %s", entry.Name())
failedCount++
// Update plugin status in database
s.updatePluginStatus(pluginPath, models.PluginStatusError, err.Error())
continue
}
loadedCount++
}
logger.Log().Infof("Loaded %d external DNS provider plugins (%d failed)", loadedCount, failedCount)
return nil
}
// LoadPlugin loads a single plugin from the specified path.
func (s *PluginLoaderService) LoadPlugin(path string) error {
// Verify signature if signatures are configured
if len(s.allowedSigs) > 0 {
pluginName := strings.TrimSuffix(filepath.Base(path), ".so")
expectedSig, ok := s.allowedSigs[pluginName]
if !ok {
return fmt.Errorf("%w: %s", dnsprovider.ErrPluginNotInAllowlist, pluginName)
}
actualSig, err := s.computeSignature(path)
if err != nil {
return fmt.Errorf("failed to compute signature: %w", err)
}
if actualSig != expectedSig {
return fmt.Errorf("%w: expected %s, got %s", dnsprovider.ErrSignatureMismatch, expectedSig, actualSig)
}
}
// Load the plugin
p, err := plugin.Open(path)
if err != nil {
return fmt.Errorf("%w: %v", dnsprovider.ErrPluginLoadFailed, err)
}
// Look up the Plugin symbol (support both T and *T)
symbol, err := p.Lookup("Plugin")
if err != nil {
return fmt.Errorf("%w: missing 'Plugin' symbol: %v", dnsprovider.ErrInvalidPlugin, err)
}
// Assert the interface (handle both value and pointer)
var provider dnsprovider.ProviderPlugin
// Try direct interface assertion first
provider, ok := symbol.(dnsprovider.ProviderPlugin)
if !ok {
// Try pointer to interface
providerPtr, ok := symbol.(*dnsprovider.ProviderPlugin)
if !ok {
return fmt.Errorf("%w: 'Plugin' symbol does not implement ProviderPlugin interface", dnsprovider.ErrInvalidPlugin)
}
provider = *providerPtr
}
// Validate provider metadata
meta := provider.Metadata()
if meta.Type == "" || meta.Name == "" {
return fmt.Errorf("%w: invalid metadata (missing type or name)", dnsprovider.ErrInvalidPlugin)
}
// Verify Go version compatibility
if meta.GoVersion != "" && meta.GoVersion != runtime.Version() {
logger.Log().Warnf("Plugin %s built with Go %s, host is %s - may cause compatibility issues",
meta.Type, meta.GoVersion, runtime.Version())
}
// Verify interface version compatibility
if meta.InterfaceVersion != "" && meta.InterfaceVersion != dnsprovider.InterfaceVersion {
return fmt.Errorf("%w: plugin interface %s, host requires %s",
dnsprovider.ErrInterfaceVersionMismatch, meta.InterfaceVersion, dnsprovider.InterfaceVersion)
}
// Initialize the provider
if err := provider.Init(); err != nil {
return fmt.Errorf("%w: %v", dnsprovider.ErrPluginInitFailed, err)
}
// Register with global registry
if err := dnsprovider.Global().Register(provider); err != nil {
// Cleanup on registration failure
_ = provider.Cleanup()
return fmt.Errorf("failed to register plugin: %w", err)
}
s.mu.Lock()
s.loadedPlugins[provider.Type()] = path
s.mu.Unlock()
// Update database with success status
now := time.Now()
s.updatePluginRecord(path, meta, models.PluginStatusLoaded, "", &now)
logger.Log().WithFields(map[string]interface{}{
"type": meta.Type,
"name": meta.Name,
"version": meta.Version,
"author": meta.Author,
}).Info("Loaded DNS provider plugin")
return nil
}
// computeSignature calculates SHA-256 hash of plugin file.
func (s *PluginLoaderService) computeSignature(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
hash := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(hash[:]), nil
}
// verifyDirectoryPermissions checks that the plugin directory has secure permissions.
func (s *PluginLoaderService) verifyDirectoryPermissions(dir string) error {
info, err := os.Stat(dir)
if err != nil {
return err
}
// On Unix-like systems, check that directory is not world-writable
if runtime.GOOS != "windows" {
mode := info.Mode().Perm()
if mode&0002 != 0 { // Check if world-writable bit is set
return fmt.Errorf("directory is world-writable (mode: %o) - this is a security risk", mode)
}
}
return nil
}
// updatePluginStatus updates the status of a plugin in the database.
func (s *PluginLoaderService) updatePluginStatus(filePath, status, errorMsg string) {
if s.db == nil {
return
}
var plugin models.Plugin
result := s.db.Where("file_path = ?", filePath).First(&plugin)
if result.Error != nil {
// Plugin not in database yet, skip
return
}
updates := map[string]interface{}{
"status": status,
"error": errorMsg,
}
if status == models.PluginStatusLoaded {
now := time.Now()
updates["loaded_at"] = &now
}
s.db.Model(&plugin).Updates(updates)
}
// updatePluginRecord creates or updates a plugin record in the database.
func (s *PluginLoaderService) updatePluginRecord(filePath string, meta dnsprovider.ProviderMetadata, status, errorMsg string, loadedAt *time.Time) {
if s.db == nil {
return
}
var plugin models.Plugin
result := s.db.Where("file_path = ?", filePath).First(&plugin)
if result.Error != nil {
// Create new record
plugin = models.Plugin{
UUID: generateUUID(),
Name: meta.Name,
Type: meta.Type,
FilePath: filePath,
Enabled: true,
Status: status,
Error: errorMsg,
Version: meta.Version,
Author: meta.Author,
LoadedAt: loadedAt,
}
s.db.Create(&plugin)
} else {
// Update existing record
updates := map[string]interface{}{
"name": meta.Name,
"type": meta.Type,
"status": status,
"error": errorMsg,
"version": meta.Version,
"author": meta.Author,
"loaded_at": loadedAt,
}
s.db.Model(&plugin).Updates(updates)
}
}
// ListLoadedPlugins returns information about loaded external plugins.
func (s *PluginLoaderService) ListLoadedPlugins() []dnsprovider.ProviderMetadata {
s.mu.RLock()
defer s.mu.RUnlock()
var plugins []dnsprovider.ProviderMetadata
for providerType := range s.loadedPlugins {
if provider, ok := dnsprovider.Global().Get(providerType); ok {
plugins = append(plugins, provider.Metadata())
}
}
return plugins
}
// IsPluginLoaded checks if a provider type was loaded from an external plugin.
func (s *PluginLoaderService) IsPluginLoaded(providerType string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.loadedPlugins[providerType]
return ok
}
// UnloadPlugin removes a plugin from the registry.
// Note: Go plugins cannot be truly unloaded from memory.
func (s *PluginLoaderService) UnloadPlugin(providerType string) error {
s.mu.Lock()
defer s.mu.Unlock()
// Get provider for cleanup
provider, ok := dnsprovider.Global().Get(providerType)
if ok {
// Call cleanup hook
if err := provider.Cleanup(); err != nil {
logger.Log().WithError(err).Warnf("Error during plugin cleanup: %s", providerType)
}
}
// Unregister from global registry
dnsprovider.Global().Unregister(providerType)
// Remove from loaded plugins map
delete(s.loadedPlugins, providerType)
logger.Log().Infof("Unloaded plugin: %s (memory not reclaimed - restart required for full unload)", providerType)
return nil
}
// Cleanup calls Cleanup() on all loaded plugins.
func (s *PluginLoaderService) Cleanup() error {
s.mu.Lock()
defer s.mu.Unlock()
for providerType := range s.loadedPlugins {
if provider, ok := dnsprovider.Global().Get(providerType); ok {
if err := provider.Cleanup(); err != nil {
logger.Log().WithError(err).Warnf("Error during plugin cleanup: %s", providerType)
}
}
}
return nil
}
// generateUUID generates a simple UUID (using timestamp-based approach for simplicity).
// In production, consider using github.com/google/uuid package.
func generateUUID() string {
return fmt.Sprintf("%d-%d", time.Now().UnixNano(), time.Now().Unix())
}