mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-06-01 13:17:21 +02:00
Added working plugin manager prototype
- Added experimental plugin UI proxy - Added plugin icon loader - Added plugin table renderer
This commit is contained in:
parent
dd4df0b4db
commit
bddff0cf2f
BIN
example/plugins/helloworld/icon.png
Normal file
BIN
example/plugins/helloworld/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
example/plugins/helloworld/icon.psd
Normal file
BIN
example/plugins/helloworld/icon.psd
Normal file
Binary file not shown.
@ -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)
|
||||
|
83
src/mod/plugins/handler.go
Normal file
83
src/mod/plugins/handler.go
Normal 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)
|
||||
}
|
@ -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
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
BIN
src/mod/plugins/no_img.psd
Normal file
Binary file not shown.
@ -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
|
||||
})
|
||||
|
41
src/mod/plugins/uirouter.go
Normal file
41
src/mod/plugins/uirouter.go
Normal 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,
|
||||
})
|
||||
|
||||
}
|
@ -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/") {
|
||||
|
@ -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")
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user