Added plugin prototype

- Added proof of concept plugin prototype
- Added wip plugin page
This commit is contained in:
Toby Chui
2025-02-25 21:14:03 +08:00
parent 20959cd6cc
commit ad13b33283
13 changed files with 546 additions and 36 deletions

View File

@ -30,6 +30,7 @@ import (
"imuslab.com/zoraxy/mod/mdns"
"imuslab.com/zoraxy/mod/netstat"
"imuslab.com/zoraxy/mod/pathrule"
"imuslab.com/zoraxy/mod/plugins"
"imuslab.com/zoraxy/mod/sshprox"
"imuslab.com/zoraxy/mod/statistic"
"imuslab.com/zoraxy/mod/statistic/analytic"
@ -42,8 +43,8 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.1.8"
DEVELOPMENT_BUILD = false /* Development: Set to false to use embedded web fs */
SYSTEM_VERSION = "3.1.9"
DEVELOPMENT_BUILD = true /* Development: Set to false to use embedded web fs */
/* System Constants */
TMP_FOLDER = "./tmp"
@ -139,6 +140,7 @@ var (
staticWebServer *webserv.WebServer //Static web server for hosting simple stuffs
forwardProxy *forwardproxy.Handler //HTTP Forward proxy, basically VPN for web browser
loadBalancer *loadbalance.RouteManager //Global scope loadbalancer, store the state of the lb routing
pluginManager *plugins.Manager //Plugin manager for managing plugins
//Authentication Provider
autheliaRouter *authelia.AutheliaRouter //Authelia router for Authelia authentication

View File

@ -1,5 +1,12 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"strings"
)
/*
Plugins Includes.go
@ -103,6 +110,23 @@ type IntroSpect struct {
SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details
}
/*
ServeIntroSpect Function
This function will check if the plugin is initialized with -introspect flag,
if so, it will print the intro spect and exit
Place this function at the beginning of your plugin main function
*/
func ServeIntroSpect(pluginSpect *IntroSpect) {
if len(os.Args) > 1 && os.Args[1] == "-introspect" {
//Print the intro spect and exit
jsonData, _ := json.MarshalIndent(pluginSpect, "", " ")
fmt.Println(string(jsonData))
os.Exit(0)
}
}
/*
ConfigureSpec Payload
@ -116,3 +140,47 @@ type ConfigureSpec struct {
RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values
//To be expanded
}
/*
RecvExecuteConfigureSpec Function
This function will read the configure spec from Zoraxy
and return the ConfigureSpec object
Place this function after ServeIntroSpect function in your plugin main function
*/
func RecvConfigureSpec() (*ConfigureSpec, error) {
for i, arg := range os.Args {
if strings.HasPrefix(arg, "-configure=") {
var configSpec ConfigureSpec
if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil {
return nil, err
}
return &configSpec, nil
} else if arg == "-configure" {
var configSpec ConfigureSpec
var nextArg string
if len(os.Args) > i+1 {
nextArg = os.Args[i+1]
if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("No port specified after -configure flag")
}
return &configSpec, nil
}
}
return nil, fmt.Errorf("No -configure flag found")
}
/*
ServeAndRecvSpec Function
This function will serve the intro spect and return the configure spec
See the ServeIntroSpect and RecvConfigureSpec for more details
*/
func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) {
ServeIntroSpect(pluginSpect)
return RecvConfigureSpec()
}

View File

@ -2,6 +2,7 @@ package plugins
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"time"
@ -29,6 +30,11 @@ func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) {
return nil, err
}
err = validatePluginSpec(pluginSpec)
if err != nil {
return nil, err
}
return &Plugin{
Spec: pluginSpec,
Enabled: false,
@ -37,12 +43,12 @@ func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) {
// GetPluginEntryPoint returns the plugin entry point
func (m *Manager) GetPluginSpec(entryPoint string) (*IntroSpect, error) {
pluginSpec := &IntroSpect{}
pluginSpec := IntroSpect{}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, entryPoint, "-introspect")
err := cmd.Run()
output, err := cmd.Output()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("plugin introspect timed out")
}
@ -50,5 +56,11 @@ func (m *Manager) GetPluginSpec(entryPoint string) (*IntroSpect, error) {
return nil, err
}
return pluginSpec, nil
// Assuming the output is JSON and needs to be unmarshaled into pluginSpec
err = json.Unmarshal(output, &pluginSpec)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal plugin spec: %v", err)
}
return &pluginSpec, nil
}

View File

@ -3,8 +3,13 @@ package plugins
import (
"encoding/json"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func (m *Manager) StartPlugin(pluginID string) error {
@ -13,8 +18,10 @@ func (m *Manager) StartPlugin(pluginID string) error {
return errors.New("plugin not found")
}
thisPlugin := plugin.(*Plugin)
//Get the plugin Entry point
pluginEntryPoint, err := m.GetPluginEntryPoint(pluginID)
pluginEntryPoint, err := m.GetPluginEntryPoint(thisPlugin.RootDir)
if err != nil {
//Plugin removed after introspect?
return err
@ -33,18 +40,85 @@ func (m *Manager) StartPlugin(pluginID string) error {
}
js, _ := json.Marshal(pluginConfiguration)
m.Log("Starting plugin "+thisPlugin.Spec.Name+" at :"+strconv.Itoa(pluginConfiguration.Port), nil)
cmd := exec.Command(absolutePath, "-configure="+string(js))
cmd.Dir = filepath.Dir(absolutePath)
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
go func() {
buf := make([]byte, 1)
lineBuf := ""
for {
n, err := stdoutPipe.Read(buf)
if n > 0 {
lineBuf += string(buf[:n])
for {
if idx := strings.IndexByte(lineBuf, '\n'); idx != -1 {
m.handlePluginSTDOUT(pluginID, lineBuf[:idx])
lineBuf = lineBuf[idx+1:]
} else {
break
}
}
}
if err != nil {
if err != io.EOF {
m.handlePluginSTDOUT(pluginID, lineBuf) // handle any remaining data
}
break
}
}
}()
// Store the cmd object so it can be accessed later for stopping the plugin
plugin.(*Plugin).Process = cmd
plugin.(*Plugin).Enabled = true
return nil
}
func (m *Manager) handlePluginSTDOUT(pluginID string, line string) {
thisPlugin, err := m.GetPluginByID(pluginID)
processID := -1
if thisPlugin.Process != nil && thisPlugin.Process.Process != nil {
// Get the process ID of the plugin
processID = thisPlugin.Process.Process.Pid
}
if err != nil {
m.Log("[unknown:"+strconv.Itoa(processID)+"] "+line, err)
return
}
m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
}
func (m *Manager) StopPlugin(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)
}
}()
plugin.(*Plugin).Enabled = false
return nil
}
// Check if the plugin is still running
func (m *Manager) PluginStillRunning(pluginID string) bool {
plugin, ok := m.LoadedPlugins.Load(pluginID)

View File

@ -6,24 +6,24 @@ import (
"os/exec"
"path/filepath"
"sync"
"time"
"imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/info/logger"
"imuslab.com/zoraxy/mod/utils"
)
type Plugin struct {
RootDir string //The root directory of the plugin
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
PluginDir string
SystemConst *RuntimeConstantValue
Database *database.Database
Logger *logger.Logger
}
type Manager struct {
@ -31,15 +31,24 @@ type Manager struct {
Options *ManagerOptions
}
// NewPluginManager creates a new plugin manager
func NewPluginManager(options *ManagerOptions) *Manager {
if options.PluginDir == "" {
options.PluginDir = "./plugins"
}
if !utils.FileExists(options.PluginDir) {
os.MkdirAll(options.PluginDir, 0755)
}
return &Manager{
LoadedPlugins: sync.Map{},
Options: options,
}
}
// LoadPlugins loads all plugins from the plugin directory
func (m *Manager) LoadPlugins() error {
// LoadPluginsFromDisk loads all plugins from the plugin directory
func (m *Manager) LoadPluginsFromDisk() error {
// Load all plugins from the plugin directory
foldersInPluginDir, err := os.ReadDir(m.Options.PluginDir)
if err != nil {
@ -54,9 +63,20 @@ func (m *Manager) LoadPlugins() error {
m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err)
continue
}
thisPlugin.RootDir = pluginPath
m.LoadedPlugins.Store(thisPlugin.Spec.ID, thisPlugin)
m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil)
//TODO: Move this to a separate function
// Enable the plugin if it is enabled in the database
err = m.StartPlugin(thisPlugin.Spec.ID)
if err != nil {
m.Log("Failed to enable plugin: "+thisPlugin.Spec.Name, err)
}
}
}
return nil
}
@ -71,34 +91,21 @@ func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) {
// 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")
err := m.StartPlugin(pluginID)
if err != nil {
return err
}
plugin.(*Plugin).Enabled = true
//TODO: Add database record
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")
err := m.StopPlugin(pluginID)
//TODO: Add database record
if err != nil {
return err
}
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
}

View File

@ -60,3 +60,22 @@ func getRandomPortNumber() int {
}
return portNo
}
func validatePluginSpec(pluginSpec *IntroSpect) error {
if pluginSpec.Name == "" {
return errors.New("plugin name is empty")
}
if pluginSpec.Description == "" {
return errors.New("plugin description is empty")
}
if pluginSpec.Author == "" {
return errors.New("plugin author is empty")
}
if pluginSpec.UIPath == "" {
return errors.New("plugin UI path is empty")
}
if pluginSpec.ID == "" {
return errors.New("plugin ID is empty")
}
return nil
}

View File

@ -26,6 +26,7 @@ import (
"imuslab.com/zoraxy/mod/mdns"
"imuslab.com/zoraxy/mod/netstat"
"imuslab.com/zoraxy/mod/pathrule"
"imuslab.com/zoraxy/mod/plugins"
"imuslab.com/zoraxy/mod/sshprox"
"imuslab.com/zoraxy/mod/statistic"
"imuslab.com/zoraxy/mod/statistic/analytic"
@ -317,6 +318,25 @@ func startupSequence() {
log.Fatal(err)
}
/*
Plugin Manager
*/
pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{
PluginDir: "./plugins",
SystemConst: &plugins.RuntimeConstantValue{
ZoraxyVersion: SYSTEM_VERSION,
ZoraxyUUID: nodeUUID,
},
Database: sysdb,
Logger: SystemWideLogger,
})
err = pluginManager.LoadPluginsFromDisk()
if err != nil {
SystemWideLogger.PrintAndLog("Plugin Manager", "Failed to load plugins", err)
}
/* Docker UX Optimizer */
if runtime.GOOS == "windows" && *runningInDocker {
SystemWideLogger.PrintAndLog("warning", "Invalid start flag combination: docker=true && runtime.GOOS == windows. Running in docker UX development mode.", nil)

View File

@ -0,0 +1,40 @@
<div class="standardContainer">
<div class="ui basic segment">
<h2>Plugins Manager</h2>
<p>Add custom features to Zoraxy</p>
</div>
<table class="ui celled table">
<thead>
<tr>
<th>Plugin Name</th>
<th>Descriptions</th>
<th>Catergory</th>
<th>Version</th>
<th>Author</th>
<th>Action</th>
</tr></thead>
<tbody>
<tr>
<td data-label="PluginName">{{plugin.name}}</td>
<td data-label="Descriptions">{{plugin.description}}</td>
<td data-label="Category">{{plugin.category}}</td>
<td data-label="Version">{{plugin.version}}</td>
<td data-label="Author">{{plugin.author}}</td>
<td data-label="Action">
<div class="ui toggle checkbox">
<input type="checkbox" name="enable">
</div>
<button class="ui basic small circular icon button"><i class="ui edit icon"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
<script>
</script>

View File

@ -78,6 +78,9 @@
<i class="simplistic user circle icon"></i> SSO / Oauth
</a>
<div class="ui divider menudivider">Others</div>
<a class="item" tag="plugins">
<i class="simplistic puzzle piece icon"></i> Plugins Manager
</a>
<a class="item" tag="webserv">
<i class="simplistic globe icon"></i> Static Web Server
</a>
@ -138,7 +141,10 @@
<!-- Web Server -->
<div id="webserv" class="functiontab" target="webserv.html"></div>
<!-- Up Time Monitor -->
<!-- Plugins -->
<div id="plugins" class="functiontab" target="plugins.html"></div>
<!-- Up Time Monitor -->
<div id="utm" class="functiontab" target="uptime.html"></div>
<!-- Network Tools -->