Browse Source

Added wip plugin system

Toby Chui 1 month ago
parent
commit
024d3dc40d
5 changed files with 418 additions and 0 deletions
  1. 118 0
      mod/plugins/includes.go
  2. 54 0
      mod/plugins/introspect.go
  3. 67 0
      mod/plugins/lifecycle.go
  4. 117 0
      mod/plugins/plugins.go
  5. 62 0
      mod/plugins/utils.go

+ 118 - 0
mod/plugins/includes.go

@@ -0,0 +1,118 @@
+package plugins
+
+/*
+	Plugins Includes.go
+
+	This file contains the common types and structs that are used by the plugins
+	If you are building a Zoraxy plugin with Golang, you can use this file to include
+	the common types and structs that are used by the plugins
+*/
+
+type PluginType int
+
+const (
+	PluginType_Router    PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic
+	PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore
+)
+
+type CaptureRule struct {
+	CapturePath     string `json:"capture_path"`
+	IncludeSubPaths bool   `json:"include_sub_paths"`
+}
+
+type ControlStatusCode int
+
+const (
+	ControlStatusCode_CAPTURED  ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic
+	ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic
+	ControlStatusCode_ERROR     ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error
+)
+
+type SubscriptionEvent struct {
+	EventName   string `json:"event_name"`
+	EventSource string `json:"event_source"`
+	Payload     string `json:"payload"` //Payload of the event, can be empty
+}
+
+type RuntimeConstantValue struct {
+	ZoraxyVersion string `json:"zoraxy_version"`
+	ZoraxyUUID    string `json:"zoraxy_uuid"`
+}
+
+/*
+IntroSpect Payload
+
+When the plugin is initialized with -introspect flag,
+the plugin shell return this payload as JSON and exit
+*/
+type IntroSpect struct {
+	/* Plugin metadata */
+	ID            string     `json:"id"`             //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname
+	Name          string     `json:"name"`           //Name of your plugin
+	Author        string     `json:"author"`         //Author name of your plugin
+	AuthorContact string     `json:"author_contact"` //Author contact of your plugin, like email
+	Description   string     `json:"description"`    //Description of your plugin
+	URL           string     `json:"url"`            //URL of your plugin
+	Type          PluginType `json:"type"`           //Type of your plugin, Router(0) or Utilities(1)
+	VersionMajor  int        `json:"version_major"`  //Major version of your plugin
+	VersionMinor  int        `json:"version_minor"`  //Minor version of your plugin
+	VersionPatch  int        `json:"version_patch"`  //Patch version of your plugin
+
+	/*
+
+		Endpoint Settings
+
+	*/
+
+	/*
+		Global Capture Settings
+
+		Once plugin is enabled these rules always applies, no matter which HTTP Proxy rule it is enabled on
+		This captures the whole traffic of Zoraxy
+
+		Notes: Will raise a warning on the UI when the user enables the plugin on a HTTP Proxy rule
+	*/
+	GlobalCapturePath    []CaptureRule `json:"global_capture_path"`    //Global traffic capture path of your plugin
+	GlobalCaptureIngress string        `json:"global_capture_ingress"` //Global traffic capture ingress path of your plugin (e.g. /g_handler)
+
+	/*
+		Always Capture Settings
+
+		Once the plugin is enabled on a given HTTP Proxy rule,
+		these always applies
+	*/
+	AlwaysCapturePath    []CaptureRule `json:"always_capture_path"`    //Always capture path of your plugin when enabled on a HTTP Proxy rule (e.g. /myapp)
+	AlwaysCaptureIngress string        `json:"always_capture_ingress"` //Always capture ingress path of your plugin when enabled on a HTTP Proxy rule (e.g. /a_handler)
+
+	/*
+		Dynamic Capture Settings
+
+		Once the plugin is enabled on a given HTTP Proxy rule,
+		the plugin can capture the request and decided if the request
+		shall be handled by itself or let it pass through
+
+	*/
+	DynmaicCaptureIngress string `json:"capture_path"` //Traffic capture path of your plugin (e.g. /capture)
+	DynamicHandleIngress  string `json:"handle_path"`  //Traffic handle path of your plugin (e.g. /handler)
+
+	/* UI Path for your plugin */
+	UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI
+
+	/* Subscriptions Settings */
+	SubscriptionPath    string            `json:"subscription_path"`    //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered
+	SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details
+}
+
+/*
+ConfigureSpec Payload
+
+Zoraxy will start your plugin with -configure flag,
+the plugin shell read this payload as JSON and configure itself
+by the supplied values like starting a web server at given port
+that listens to 127.0.0.1:port
+*/
+type ConfigureSpec struct {
+	Port         int                  `json:"port"`          //Port to listen
+	RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values
+	//To be expanded
+}

+ 54 - 0
mod/plugins/introspect.go

@@ -0,0 +1,54 @@
+package plugins
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"time"
+)
+
+// LoadPlugin loads a plugin from the plugin directory
+func (m *Manager) IsValidPluginFolder(path string) bool {
+	_, err := m.GetPluginEntryPoint(path)
+	return err == nil
+}
+
+/*
+LoadPluginSpec loads a plugin specification from the plugin directory
+Zoraxy will start the plugin binary or the entry point script
+with -introspect flag to get the plugin specification
+*/
+func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) {
+	pluginEntryPoint, err := m.GetPluginEntryPoint(pluginPath)
+	if err != nil {
+		return nil, err
+	}
+
+	pluginSpec, err := m.GetPluginSpec(pluginEntryPoint)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Plugin{
+		Spec:    pluginSpec,
+		Enabled: false,
+	}, nil
+}
+
+// GetPluginEntryPoint returns the plugin entry point
+func (m *Manager) GetPluginSpec(entryPoint string) (*IntroSpect, error) {
+	pluginSpec := &IntroSpect{}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, entryPoint, "-introspect")
+	err := cmd.Run()
+	if ctx.Err() == context.DeadlineExceeded {
+		return nil, fmt.Errorf("plugin introspect timed out")
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	return pluginSpec, nil
+}

+ 67 - 0
mod/plugins/lifecycle.go

@@ -0,0 +1,67 @@
+package plugins
+
+import (
+	"encoding/json"
+	"errors"
+	"os/exec"
+	"path/filepath"
+)
+
+func (m *Manager) StartPlugin(pluginID string) error {
+	plugin, ok := m.LoadedPlugins.Load(pluginID)
+	if !ok {
+		return errors.New("plugin not found")
+	}
+
+	//Get the plugin Entry point
+	pluginEntryPoint, err := m.GetPluginEntryPoint(pluginID)
+	if err != nil {
+		//Plugin removed after introspect?
+		return err
+	}
+
+	//Get the absolute path of the plugin entry point to prevent messing up with the cwd
+	absolutePath, err := filepath.Abs(pluginEntryPoint)
+	if err != nil {
+		return err
+	}
+
+	//Prepare plugin start configuration
+	pluginConfiguration := ConfigureSpec{
+		Port:         getRandomPortNumber(),
+		RuntimeConst: *m.Options.SystemConst,
+	}
+	js, _ := json.Marshal(pluginConfiguration)
+
+	cmd := exec.Command(absolutePath, "-configure="+string(js))
+	cmd.Dir = filepath.Dir(absolutePath)
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	// Store the cmd object so it can be accessed later for stopping the plugin
+	plugin.(*Plugin).Process = cmd
+	plugin.(*Plugin).Enabled = true
+	return nil
+}
+
+// Check if the plugin is still running
+func (m *Manager) PluginStillRunning(pluginID string) bool {
+	plugin, ok := m.LoadedPlugins.Load(pluginID)
+	if !ok {
+		return false
+	}
+	return plugin.(*Plugin).Process.ProcessState == nil
+}
+
+// BlockUntilAllProcessExited blocks until all the plugins processes have exited
+func (m *Manager) BlockUntilAllProcessExited() {
+	m.LoadedPlugins.Range(func(key, value interface{}) bool {
+		plugin := value.(*Plugin)
+		if m.PluginStillRunning(value.(*Plugin).Spec.ID) {
+			//Wait for the plugin to exit
+			plugin.Process.Wait()
+		}
+		return true
+	})
+}

+ 117 - 0
mod/plugins/plugins.go

@@ -0,0 +1,117 @@
+package plugins
+
+import (
+	"errors"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sync"
+	"time"
+
+	"imuslab.com/zoraxy/mod/database"
+	"imuslab.com/zoraxy/mod/info/logger"
+)
+
+type Plugin struct {
+	Spec    *IntroSpect //The plugin specification
+	Process *exec.Cmd   //The process of the plugin
+	Enabled bool        //Whether the plugin is enabled
+}
+
+type ManagerOptions struct {
+	ZoraxyVersion string
+	PluginDir     string
+	SystemConst   *RuntimeConstantValue
+	Database      database.Database
+	Logger        *logger.Logger
+}
+
+type Manager struct {
+	LoadedPlugins sync.Map //Storing *Plugin
+	Options       *ManagerOptions
+}
+
+func NewPluginManager(options *ManagerOptions) *Manager {
+	return &Manager{
+		LoadedPlugins: sync.Map{},
+		Options:       options,
+	}
+}
+
+// LoadPlugins loads all plugins from the plugin directory
+func (m *Manager) LoadPlugins() error {
+	// Load all plugins from the plugin directory
+	foldersInPluginDir, err := os.ReadDir(m.Options.PluginDir)
+	if err != nil {
+		return err
+	}
+
+	for _, folder := range foldersInPluginDir {
+		if folder.IsDir() {
+			pluginPath := filepath.Join(m.Options.PluginDir, folder.Name())
+			thisPlugin, err := m.LoadPluginSpec(pluginPath)
+			if err != nil {
+				m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err)
+				continue
+			}
+			m.LoadedPlugins.Store(thisPlugin.Spec.ID, thisPlugin)
+		}
+	}
+	return nil
+}
+
+// GetPluginByID returns a plugin by its ID
+func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) {
+	plugin, ok := m.LoadedPlugins.Load(pluginID)
+	if !ok {
+		return nil, errors.New("plugin not found")
+	}
+	return plugin.(*Plugin), nil
+}
+
+// EnablePlugin enables a plugin
+func (m *Manager) EnablePlugin(pluginID string) error {
+	plugin, ok := m.LoadedPlugins.Load(pluginID)
+	if !ok {
+		return errors.New("plugin not found")
+	}
+	plugin.(*Plugin).Enabled = true
+	return nil
+}
+
+// DisablePlugin disables a plugin
+func (m *Manager) DisablePlugin(pluginID string) error {
+	plugin, ok := m.LoadedPlugins.Load(pluginID)
+	if !ok {
+		return errors.New("plugin not found")
+	}
+
+	thisPlugin := plugin.(*Plugin)
+	thisPlugin.Process.Process.Signal(os.Interrupt)
+	go func() {
+		//Wait for 10 seconds for the plugin to stop gracefully
+		time.Sleep(10 * time.Second)
+		if thisPlugin.Process.ProcessState == nil || !thisPlugin.Process.ProcessState.Exited() {
+			m.Log("Plugin "+thisPlugin.Spec.Name+" failed to stop gracefully, killing it", nil)
+			thisPlugin.Process.Process.Kill()
+		} else {
+			m.Log("Plugin "+thisPlugin.Spec.Name+" background process stopped", nil)
+		}
+	}()
+	thisPlugin.Enabled = false
+	return nil
+}
+
+// Terminate all plugins and exit
+func (m *Manager) Close() {
+	m.LoadedPlugins.Range(func(key, value interface{}) bool {
+		plugin := value.(*Plugin)
+		if plugin.Enabled {
+			m.DisablePlugin(plugin.Spec.ID)
+		}
+		return true
+	})
+
+	//Wait until all loaded plugin process are terminated
+	m.BlockUntilAllProcessExited()
+}

+ 62 - 0
mod/plugins/utils.go

@@ -0,0 +1,62 @@
+package plugins
+
+import (
+	"errors"
+	"math/rand"
+	"os"
+	"path/filepath"
+	"runtime"
+
+	"imuslab.com/zoraxy/mod/netutils"
+)
+
+/*
+Check if the folder contains a valid plugin in either one of the forms
+
+1. Contain a file that have the same name as its parent directory, either executable or .exe on Windows
+2. Contain a start.sh or start.bat file
+
+Return the path of the plugin entry point if found
+*/
+func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) {
+	info, err := os.Stat(folderpath)
+	if err != nil {
+		return "", err
+	}
+	if !info.IsDir() {
+		return "", errors.New("path is not a directory")
+	}
+	expectedBinaryPath := filepath.Join(folderpath, filepath.Base(folderpath))
+	if runtime.GOOS == "windows" {
+		expectedBinaryPath += ".exe"
+	}
+
+	if _, err := os.Stat(expectedBinaryPath); err == nil {
+		return expectedBinaryPath, nil
+	}
+
+	if _, err := os.Stat(filepath.Join(folderpath, "start.sh")); err == nil {
+		return filepath.Join(folderpath, "start.sh"), nil
+	}
+
+	if _, err := os.Stat(filepath.Join(folderpath, "start.bat")); err == nil {
+		return filepath.Join(folderpath, "start.bat"), nil
+	}
+
+	return "", errors.New("No valid entry point found")
+}
+
+// Log logs a message with an optional error
+func (m *Manager) Log(message string, err error) {
+	m.Options.Logger.PrintAndLog("plugin-manager", message, err)
+}
+
+// getRandomPortNumber generates a random port number between 49152 and 65535
+func getRandomPortNumber() int {
+	portNo := rand.Intn(65535-49152) + 49152
+	//Check if the port is already in use
+	for netutils.CheckIfPortOccupied(portNo) {
+		portNo = rand.Intn(65535-49152) + 49152
+	}
+	return portNo
+}