Added working plugin manager prototype

- Added experimental plugin UI proxy
- Added plugin icon loader
- Added plugin table renderer
This commit is contained in:
Toby Chui 2025-02-27 22:27:13 +08:00
parent dd4df0b4db
commit bddff0cf2f
13 changed files with 294 additions and 39 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

View File

@ -235,6 +235,13 @@ func RegisterNetworkUtilsAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/tools/fwdproxy/port", forwardProxy.HandlePort)
}
func RegisterPluginAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/plugins/list", pluginManager.HandleListPlugins)
authRouter.HandleFunc("/api/plugins/enable", pluginManager.HandleEnablePlugin)
authRouter.HandleFunc("/api/plugins/disable", pluginManager.HandleDisablePlugin)
authRouter.HandleFunc("/api/plugins/icon", pluginManager.HandleLoadPluginIcon)
}
// Register the APIs for Auth functions, due to scoping issue some functions are defined here
func RegisterAuthAPIs(requireAuth bool, targetMux *http.ServeMux) {
targetMux.HandleFunc("/api/auth/login", authAgent.HandleLogin)
@ -340,6 +347,7 @@ func initAPIs(targetMux *http.ServeMux) {
RegisterNetworkUtilsAPIs(authRouter)
RegisterACMEAndAutoRenewerAPIs(authRouter)
RegisterStaticWebServerAPIs(authRouter)
RegisterPluginAPIs(authRouter)
//Account Reset
targetMux.HandleFunc("/api/account/reset", HandleAdminAccountResetEmail)

View File

@ -0,0 +1,83 @@
package plugins
import (
"bytes"
"encoding/json"
"net/http"
"path/filepath"
"time"
"imuslab.com/zoraxy/mod/utils"
)
// HandleListPlugins handles the request to list all loaded plugins
func (m *Manager) HandleListPlugins(w http.ResponseWriter, r *http.Request) {
plugins, err := m.ListLoadedPlugins()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
js, err := json.Marshal(plugins)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
utils.SendJSONResponse(w, string(js))
}
func (m *Manager) HandleLoadPluginIcon(w http.ResponseWriter, r *http.Request) {
pluginID, err := utils.GetPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
plugin, err := m.GetPluginByID(pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Check if the icon.png exists under plugin root directory
expectedIconPath := filepath.Join(plugin.RootDir, "icon.png")
if !utils.FileExists(expectedIconPath) {
http.ServeContent(w, r, "no_img.png", time.Now(), bytes.NewReader(noImg))
return
}
http.ServeFile(w, r, expectedIconPath)
}
func (m *Manager) HandleEnablePlugin(w http.ResponseWriter, r *http.Request) {
pluginID, err := utils.PostPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
err = m.EnablePlugin(pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
utils.SendOK(w)
}
func (m *Manager) HandleDisablePlugin(w http.ResponseWriter, r *http.Request) {
pluginID, err := utils.PostPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
err = m.DisablePlugin(pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
utils.SendOK(w)
}

View File

@ -4,12 +4,15 @@ import (
"encoding/json"
"errors"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
)
func (m *Manager) StartPlugin(pluginID string) error {
@ -77,18 +80,49 @@ func (m *Manager) StartPlugin(pluginID string) error {
}
}()
//Create a UI forwarder if the plugin has UI
err = m.StartUIHandlerForPlugin(thisPlugin, pluginConfiguration.Port)
if err != nil {
return err
}
// Store the cmd object so it can be accessed later for stopping the plugin
plugin.(*Plugin).Process = cmd
plugin.(*Plugin).process = cmd
plugin.(*Plugin).Enabled = true
return nil
}
// StartUIHandlerForPlugin starts a UI handler for the plugin
func (m *Manager) StartUIHandlerForPlugin(targetPlugin *Plugin, pluginListeningPort int) error {
// Create a dpcore object to reverse proxy the plugin ui
pluginUIRelPath := targetPlugin.Spec.UIPath
if !strings.HasPrefix(pluginUIRelPath, "/") {
pluginUIRelPath = "/" + pluginUIRelPath
}
pluginUIURL, err := url.Parse("http://127.0.0.1:" + strconv.Itoa(pluginListeningPort) + pluginUIRelPath)
if err != nil {
return err
}
if targetPlugin.Spec.UIPath != "" {
targetPlugin.uiProxy = dpcore.NewDynamicProxyCore(
pluginUIURL,
"",
&dpcore.DpcoreOptions{
IgnoreTLSVerification: true,
},
)
m.LoadedPlugins.Store(targetPlugin.Spec.ID, targetPlugin)
}
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 {
if thisPlugin.process != nil && thisPlugin.process.Process != nil {
// Get the process ID of the plugin
processID = thisPlugin.Process.Process.Pid
processID = thisPlugin.process.Process.Pid
}
if err != nil {
m.Log("[unknown:"+strconv.Itoa(processID)+"] "+line, err)
@ -104,16 +138,19 @@ func (m *Manager) StopPlugin(pluginID string) error {
}
thisPlugin := plugin.(*Plugin)
thisPlugin.Process.Process.Signal(os.Interrupt)
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() {
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()
thisPlugin.process.Process.Kill()
} else {
m.Log("Plugin "+thisPlugin.Spec.Name+" background process stopped", nil)
}
//Remove the UI proxy
thisPlugin.uiProxy = nil
}()
plugin.(*Plugin).Enabled = false
return nil
@ -125,7 +162,10 @@ func (m *Manager) PluginStillRunning(pluginID string) bool {
if !ok {
return false
}
return plugin.(*Plugin).Process.ProcessState == nil
if plugin.(*Plugin).process == nil {
return false
}
return plugin.(*Plugin).process.ProcessState == nil
}
// BlockUntilAllProcessExited blocks until all the plugins processes have exited
@ -134,7 +174,7 @@ func (m *Manager) BlockUntilAllProcessExited() {
plugin := value.(*Plugin)
if m.PluginStillRunning(value.(*Plugin).Spec.ID) {
//Wait for the plugin to exit
plugin.Process.Wait()
plugin.process.Wait()
}
return true
})

BIN
src/mod/plugins/no_img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
src/mod/plugins/no_img.psd Normal file

Binary file not shown.

View File

@ -2,12 +2,16 @@ package plugins
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
_ "embed"
"imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
"imuslab.com/zoraxy/mod/info/logger"
"imuslab.com/zoraxy/mod/utils"
)
@ -15,8 +19,10 @@ import (
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
uiProxy *dpcore.ReverseProxy //The reverse proxy for the plugin UI
process *exec.Cmd //The process of the plugin
}
type ManagerOptions struct {
@ -31,16 +37,22 @@ type Manager struct {
Options *ManagerOptions
}
//go:embed no_img.png
var noImg []byte
// NewPluginManager creates a new plugin manager
func NewPluginManager(options *ManagerOptions) *Manager {
//Create plugin directory if not exists
if options.PluginDir == "" {
options.PluginDir = "./plugins"
}
if !utils.FileExists(options.PluginDir) {
os.MkdirAll(options.PluginDir, 0755)
}
//Create database table
options.Database.NewTable("plugins")
return &Manager{
LoadedPlugins: sync.Map{},
Options: options,
@ -63,17 +75,18 @@ func (m *Manager) LoadPluginsFromDisk() error {
m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err)
continue
}
thisPlugin.RootDir = pluginPath
thisPlugin.RootDir = filepath.ToSlash(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)
// If the plugin was enabled, start it now
fmt.Println(m.GetPluginPreviousEnableState(thisPlugin.Spec.ID))
if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) {
err = m.StartPlugin(thisPlugin.Spec.ID)
if err != nil {
m.Log("Failed to enable plugin: "+thisPlugin.Spec.Name, err)
}
}
}
}
@ -95,26 +108,48 @@ func (m *Manager) EnablePlugin(pluginID string) error {
if err != nil {
return err
}
//TODO: Add database record
m.Options.Database.Write("plugins", pluginID, true)
return nil
}
// DisablePlugin disables a plugin
func (m *Manager) DisablePlugin(pluginID string) error {
err := m.StopPlugin(pluginID)
//TODO: Add database record
m.Options.Database.Write("plugins", pluginID, false)
if err != nil {
return err
}
return nil
}
// GetPluginPreviousEnableState returns the previous enable state of a plugin
func (m *Manager) GetPluginPreviousEnableState(pluginID string) bool {
enableState := true
err := m.Options.Database.Read("plugins", pluginID, &enableState)
if err != nil {
//Default to true
return true
}
return enableState
}
// ListLoadedPlugins returns a list of loaded plugins
func (m *Manager) ListLoadedPlugins() ([]*Plugin, error) {
var plugins []*Plugin
m.LoadedPlugins.Range(func(key, value interface{}) bool {
plugin := value.(*Plugin)
plugins = append(plugins, plugin)
return true
})
return plugins, 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)
m.StopPlugin(plugin.Spec.ID)
}
return true
})

View File

@ -0,0 +1,41 @@
package plugins
import (
"net/http"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
"imuslab.com/zoraxy/mod/utils"
)
// HandlePluginUI handles the request to the plugin UI
// This function will route the request to the correct plugin UI handler
func (m *Manager) HandlePluginUI(pluginID string, w http.ResponseWriter, r *http.Request) {
plugin, err := m.GetPluginByID(pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Check if the plugin has UI
if plugin.Spec.UIPath == "" {
utils.SendErrorResponse(w, "Plugin does not have UI")
return
}
//Check if the plugin has UI handler
if plugin.uiProxy == nil {
utils.SendErrorResponse(w, "Plugin does not have UI handler")
return
}
//Call the plugin UI handler
plugin.uiProxy.ServeHTTP(w, r, &dpcore.ResponseRewriteRuleSet{
UseTLS: false,
OriginalHost: r.Host,
ProxyDomain: r.Host,
NoCache: true,
PathPrefix: "/plugin.ui/" + pluginID,
Version: m.Options.SystemConst.ZoraxyVersion,
})
}

View File

@ -58,6 +58,19 @@ func FSHandler(handler http.Handler) http.Handler {
return
}
//For Plugin Routing
if strings.HasPrefix(r.URL.Path, "/plugin.ui/") {
//Extract the plugin ID from the request path
parts := strings.Split(r.URL.Path, "/")
if len(parts) > 2 {
pluginID := parts[2]
pluginManager.HandlePluginUI(pluginID, w, r)
} else {
http.Error(w, "Invalid Usage", http.StatusInternalServerError)
}
return
}
//For WebSSH Routing
//Example URL Path: /web.ssh/{{instance_uuid}}/*
if strings.HasPrefix(r.URL.Path, "/web.ssh/") {

View File

@ -384,6 +384,10 @@ func ShutdownSeq() {
if acmeAutoRenewer != nil {
acmeAutoRenewer.Close()
}
//Close the plugin manager
SystemWideLogger.Println("Shutting down plugin manager")
pluginManager.Close()
//Remove the tmp folder
SystemWideLogger.Println("Cleaning up tmp files")
os.RemoveAll("./tmp")

View File

@ -1,7 +1,7 @@
<div class="standardContainer">
<div class="ui basic segment">
<h2>Plugins Manager</h2>
<p>Add custom features to Zoraxy</p>
<h2>Plugins</h2>
<p>Custom features on Zoraxy</p>
</div>
<table class="ui celled table">
<thead>
@ -9,30 +9,61 @@
<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 id="pluginTable">
</tbody>
</table>
</div>
<script>
function initiatePluginList(){
$.get(`/api/plugins/list`, function(data){
const tbody = $("#pluginTable");
tbody.empty();
data.forEach(plugin => {
let authorContact = plugin.Spec.author_contact;
if(!authorContact.startsWith('http')){
authorContact = `mailto:${authorContact}`;
}
let versionString = `v${plugin.Spec.version_major}.${plugin.Spec.version_minor}.${plugin.Spec.version_patch}`;
const row = `
<tr>
<td data-label="PluginName">
<h4 class="ui header">
<img src="/api/plugins/icon?plugin_id=${plugin.Spec.id}" class="ui image">
<div class="content">
${plugin.Spec.name}
<div class="sub header">${versionString} by <a href="${authorContact}" target="_blank">${plugin.Spec.author}</a></div>
</div>
</h4>
</td>
<td data-label="Descriptions">${plugin.Spec.description}<br>
<a href="${plugin.Spec.url}" target="_blank">${plugin.Spec.url}</a></td>
<td data-label="Category">${plugin.Spec.type==0?"Router":"Utilities"}</td>
<td data-label="Action">
<div class="ui toggle checkbox">
<input type="checkbox" name="enable" ${plugin.Enabled ? 'checked' : ''}>
</div>
<button class="ui basic small circular icon button" onclick="openPluginUI('${plugin.Spec.id}');"><i class="ui edit icon"></i></button>
</td>
</tr>
`;
tbody.append(row);
});
console.log(data);
});
}
function openPluginUI(pluginid){
showSideWrapper(`/plugin.ui/${pluginid}/`);
}
initiatePluginList();
</script>

View File

@ -79,7 +79,7 @@
</a>
<div class="ui divider menudivider">Others</div>
<a class="item" tag="plugins">
<i class="simplistic puzzle piece icon"></i> Plugins Manager
<i class="simplistic puzzle piece icon"></i> Plugins
</a>
<a class="item" tag="webserv">
<i class="simplistic globe icon"></i> Static Web Server