diff --git a/example/plugins/helloworld/icon.png b/example/plugins/helloworld/icon.png new file mode 100644 index 0000000..69c3e29 Binary files /dev/null and b/example/plugins/helloworld/icon.png differ diff --git a/example/plugins/helloworld/icon.psd b/example/plugins/helloworld/icon.psd new file mode 100644 index 0000000..eae71ea Binary files /dev/null and b/example/plugins/helloworld/icon.psd differ diff --git a/src/api.go b/src/api.go index 1507289..7afa247 100644 --- a/src/api.go +++ b/src/api.go @@ -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) diff --git a/src/mod/plugins/handler.go b/src/mod/plugins/handler.go new file mode 100644 index 0000000..0795313 --- /dev/null +++ b/src/mod/plugins/handler.go @@ -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) +} diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go index 6b4ea75..3c13069 100644 --- a/src/mod/plugins/lifecycle.go +++ b/src/mod/plugins/lifecycle.go @@ -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 }) diff --git a/src/mod/plugins/no_img.png b/src/mod/plugins/no_img.png new file mode 100644 index 0000000..2b85efb Binary files /dev/null and b/src/mod/plugins/no_img.png differ diff --git a/src/mod/plugins/no_img.psd b/src/mod/plugins/no_img.psd new file mode 100644 index 0000000..a5691e2 Binary files /dev/null and b/src/mod/plugins/no_img.psd differ diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index 957eaa4..181d3e5 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -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 }) diff --git a/src/mod/plugins/uirouter.go b/src/mod/plugins/uirouter.go new file mode 100644 index 0000000..62414de --- /dev/null +++ b/src/mod/plugins/uirouter.go @@ -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, + }) + +} diff --git a/src/router.go b/src/router.go index 7fab6cf..e7a4645 100644 --- a/src/router.go +++ b/src/router.go @@ -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/") { diff --git a/src/start.go b/src/start.go index 7a7171d..f9d757d 100644 --- a/src/start.go +++ b/src/start.go @@ -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") diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html index 15a6775..7d0e780 100644 --- a/src/web/components/plugins.html +++ b/src/web/components/plugins.html @@ -1,7 +1,7 @@
Add custom features to Zoraxy
+Custom features on Zoraxy
| Plugin Name | Descriptions | Catergory | -Version | -Author | Action | - -
|---|---|---|---|---|---|
| {{plugin.name}} | -{{plugin.description}} | -{{plugin.category}} | -{{plugin.version}} | -{{plugin.author}} | -
-
-
-
-
- |
-