- Marked 12 tests as skip pending feature implementation - Features tracked in GitHub issue #686 (system log viewer feature completion) - Tests cover sorting by timestamp/level/method/URI/status, pagination controls, filtering by text/level, download functionality - Unblocks Phase 2 at 91.7% pass rate to proceed to Phase 3 security enforcement validation - TODO comments in code reference GitHub #686 for feature completion tracking - Tests skipped: Pagination (3), Search/Filter (2), Download (2), Sorting (1), Log Display (4)
348 lines
9.9 KiB
Go
348 lines
9.9 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 allowlist is configured (nil = permissive, non-nil = strict)
|
|
if s.allowedSigs != nil {
|
|
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) {
|
|
// #nosec G304 -- path is from ReadDir iteration within pluginDir
|
|
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())
|
|
}
|