Reticulum-Go/pkg/interfaces/plugin.go

184 lines
5.4 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
//go:build !tinygo
package interfaces
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"quad4/reticulum-go/pkg/common"
"quad4/reticulum-go/pkg/debug"
)
// ExternalFactory builds a custom interface for an unknown config type.
// Embedders register factories for in-process drivers. Operators can also
// drop manifests or executables under {config_dir}/interfaces/.
type ExternalFactory func(name string, cfg *common.InterfaceConfig, ctx *FromConfigContext) (Interface, error)
var (
externalFactoriesMu sync.RWMutex
externalFactories = map[string]ExternalFactory{}
)
// RegisterExternalFactory registers an in-process builder for typeName.
// Replaces any prior registration for the same name.
func RegisterExternalFactory(typeName string, factory ExternalFactory) {
if typeName == "" || factory == nil {
return
}
externalFactoriesMu.Lock()
externalFactories[typeName] = factory
externalFactoriesMu.Unlock()
}
// UnregisterExternalFactory removes a previously registered factory.
func UnregisterExternalFactory(typeName string) {
externalFactoriesMu.Lock()
delete(externalFactories, typeName)
externalFactoriesMu.Unlock()
}
type pluginManifest struct {
Driver string `json:"driver"`
Command string `json:"command"`
RespawnDelay int `json:"respawn_delay"`
}
// loadExternalInterface resolves unknown types via registered factories,
// JSON manifests, or executable pipe drivers under interfaces/.
func loadExternalInterface(name string, cfg *common.InterfaceConfig, ctx *FromConfigContext) (Interface, error) {
if cfg == nil || cfg.Type == "" {
return nil, errors.New("nil interface config")
}
typeName := cfg.Type
externalFactoriesMu.RLock()
factory := externalFactories[typeName]
externalFactoriesMu.RUnlock()
if factory != nil {
debug.Log(debug.DebugInfo, "Loading external interface from registered factory", "type", typeName, "name", name)
return factory(name, cfg, ctx)
}
dir := interfacesPluginDir(ctx)
if dir == "" {
return nil, fmt.Errorf("unsupported interface type %q (no interfaces plugin directory)", typeName)
}
if err := validatePluginTypeName(typeName); err != nil {
return nil, err
}
for _, base := range []string{
filepath.Join(dir, typeName+".json"),
filepath.Join(dir, typeName+".manifest"),
} {
if !pluginPathContained(dir, base) {
return nil, fmt.Errorf("invalid plugin type %q", typeName)
}
iface, err := loadManifestInterface(name, cfg, ctx, base)
if err == nil {
debug.Log(debug.DebugInfo, "Loading external interface from manifest", "path", base, "name", name)
return iface, nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("external interface %q: %w", typeName, err)
}
}
execPath := filepath.Join(dir, typeName)
if !pluginPathContained(dir, execPath) {
return nil, fmt.Errorf("invalid plugin type %q", typeName)
}
if st, err := os.Stat(execPath); err == nil && !st.IsDir() && isExecutable(st) {
debug.Log(debug.DebugInfo, "Loading external interface from executable", "path", execPath, "name", name)
return newPipeFromPlugin(name, cfg, ctx, execPath, 0)
}
return nil, fmt.Errorf("unsupported interface type %q (no factory, manifest, or executable in %s)", typeName, dir)
}
func validatePluginTypeName(typeName string) error {
if typeName == "" || filepath.IsAbs(typeName) || typeName != filepath.Base(typeName) {
return fmt.Errorf("invalid plugin type %q", typeName)
}
return nil
}
func pluginPathContained(dir, path string) bool {
cleanDir := filepath.Clean(dir)
cleanPath := filepath.Clean(path)
rel, err := filepath.Rel(cleanDir, cleanPath)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
func interfacesPluginDir(ctx *FromConfigContext) string {
if ctx != nil && ctx.ConfigDir != "" {
return filepath.Join(ctx.ConfigDir, "interfaces")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".reticulum-go", "interfaces")
}
func loadManifestInterface(name string, cfg *common.InterfaceConfig, ctx *FromConfigContext, path string) (Interface, error) {
data, err := os.ReadFile(path) // #nosec G304 -- path under plugin dir after validatePluginTypeName and pluginPathContained
if err != nil {
return nil, err
}
var man pluginManifest
if err := json.Unmarshal(data, &man); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
driver := man.Driver
if driver == "" {
driver = "pipe"
}
switch driver {
case "pipe":
cmd := man.Command
if cmd == "" {
return nil, fmt.Errorf("%s: pipe driver requires command", path)
}
return newPipeFromPlugin(name, cfg, ctx, cmd, man.RespawnDelay)
default:
return nil, fmt.Errorf("%s: unknown driver %q", path, driver)
}
}
func newPipeFromPlugin(name string, cfg *common.InterfaceConfig, ctx *FromConfigContext, command string, respawnDelaySec int) (Interface, error) {
delaySec := respawnDelaySec
if cfg != nil && cfg.RespawnDelay > 0 {
delaySec = cfg.RespawnDelay
}
if delaySec <= 0 {
delaySec = 5
}
if cfg != nil && cfg.Command != "" {
command = cfg.Command
}
panicOnErr := ctx != nil && ctx.PanicOnInterfaceError
enabled := true
if cfg != nil {
enabled = cfg.Enabled
}
return NewPipeInterface(name, command, enabled, time.Duration(delaySec)*time.Second, panicOnErr)
}
func isExecutable(st os.FileInfo) bool {
mode := st.Mode()
return mode&0o111 != 0
}