Added plugin interface definations

- Added wip plugin interface
- Merged in PR for lego update
- Minor code optimization
This commit is contained in:
Toby Chui
2025-02-19 21:25:50 +08:00
parent de9d3bfb65
commit 1116b643b5
9 changed files with 620 additions and 223 deletions

View File

@@ -314,7 +314,7 @@ func (router *Router) Restart() error {
return err
}
time.Sleep(800 * time.Millisecond)
time.Sleep(100 * time.Millisecond)
// Start the server
err = router.StartProxyService()
if err != nil {

118
src/mod/plugins/includes.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
src/mod/plugins/plugins.go Normal file
View File

@@ -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
src/mod/plugins/utils.go Normal file
View File

@@ -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
}