From 28a0a837baab4ba732ac4a3896e78f21caaa2ba6 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Sat, 1 Mar 2025 10:00:33 +0800 Subject: [PATCH] Plugin lifecycle optimization - Added term flow before plugin is killed - Updated example implementations - Added SIGINT to Zoraxy for shutdown sequence (Fixes #561 ?) --- example/plugins/helloworld/main.go | 17 ++-- .../zoraxy_plugin/embed_webserver.go | 23 +++++ .../helloworld/zoraxy_plugin/zoraxy_plugin.go | 40 +------- example/plugins/ztnc/main.go | 14 +-- .../plugins/ztnc/mod/zoraxy_plugin/README.txt | 19 ++++ .../ztnc/mod/zoraxy_plugin/embed_webserver.go | 22 +++++ .../ztnc/mod/zoraxy_plugin/zoraxy_plugin.go | 40 +------- src/def.go | 2 +- src/main.go | 2 +- src/mod/plugins/lifecycle.go | 57 +++++++++-- src/mod/plugins/plugins.go | 2 +- .../plugins/zoraxy_plugin/embed_webserver.go | 22 +++++ .../plugins/zoraxy_plugin/zoraxy_plugin.go | 24 ----- src/web/components/plugincontext.html | 12 ++- src/web/components/plugins.html | 96 ++++++++++++++++--- src/web/index.html | 2 +- 16 files changed, 255 insertions(+), 139 deletions(-) create mode 100644 example/plugins/ztnc/mod/zoraxy_plugin/README.txt diff --git a/example/plugins/helloworld/main.go b/example/plugins/helloworld/main.go index d4aec15..74188cf 100644 --- a/example/plugins/helloworld/main.go +++ b/example/plugins/helloworld/main.go @@ -43,16 +43,21 @@ func main() { panic(err) } - // Register the shutdown handler - plugin.RegisterShutdownHandler(func() { + // Create a new PluginEmbedUIRouter that will serve the UI from web folder + // The router will also help to handle the termination of the plugin when + // a user wants to stop the plugin via Zoraxy Web UI + embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH) + embedWebRouter.RegisterTerminateHandler(func() { // Do cleanup here if needed fmt.Println("Hello World Plugin Exited") - }) - - embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH) + }, nil) // Serve the hello world page in the www folder http.Handle(UI_PATH, embedWebRouter.Handler()) fmt.Println("Hello World started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port)) - http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) + err = http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) + if err != nil { + panic(err) + } + } diff --git a/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go b/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go index 2a264a2..c529e99 100644 --- a/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go +++ b/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go @@ -6,6 +6,7 @@ import ( "io/fs" "net/http" "net/url" + "os" "strings" "time" ) @@ -15,6 +16,8 @@ type PluginUiRouter struct { TargetFs *embed.FS //The embed.FS where the UI files are stored TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + + terminateHandler func() //The handler to be called when the plugin is terminated } // NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS @@ -91,6 +94,7 @@ func (p *PluginUiRouter) Handler() http.Handler { rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") r.URL, _ = url.Parse(rewrittenURL) r.RequestURI = rewrittenURL + //Serve the file from the embed.FS subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) if err != nil { @@ -103,3 +107,22 @@ func (p *PluginUiRouter) Handler() http.Handler { p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) }) } + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} diff --git a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go index 1691591..b316e6d 100644 --- a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go +++ b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go @@ -4,9 +4,7 @@ import ( "encoding/json" "fmt" "os" - "os/signal" "strings" - "syscall" ) /* @@ -79,9 +77,8 @@ type IntroSpect struct { 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 + GlobalCapturePaths []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) /* @@ -90,20 +87,9 @@ type IntroSpect struct { 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) + AlwaysCapturePaths []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 @@ -186,25 +172,3 @@ func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { ServeIntroSpect(pluginSpect) return RecvConfigureSpec() } - -/* - -Shutdown handler - -This function will register a shutdown handler for the plugin -The shutdown callback will be called when the plugin is shutting down -You can use this to clean up resources like closing database connections -*/ - -func RegisterShutdownHandler(shutdownCallback func()) { - // Set up a channel to receive OS signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Start a goroutine to listen for signals - go func() { - <-sigChan - shutdownCallback() - os.Exit(0) - }() -} diff --git a/example/plugins/ztnc/main.go b/example/plugins/ztnc/main.go index b302275..ee96033 100644 --- a/example/plugins/ztnc/main.go +++ b/example/plugins/ztnc/main.go @@ -51,17 +51,17 @@ func main() { panic(err) } + // Create a new PluginEmbedUIRouter that will serve the UI from web folder + uiRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, EMBED_FS_ROOT, UI_RELPATH) + // Register the shutdown handler - plugin.RegisterShutdownHandler(func() { - fmt.Println("Shutting down ZeroTier Network Controller") + uiRouter.RegisterTerminateHandler(func() { + // Do cleanup here if needed if sysdb != nil { sysdb.Close() } - fmt.Println("ZeroTier Network Controller Exited") - }) - - // Create a new PluginEmbedUIRouter that will serve the UI from web folder - uiRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, EMBED_FS_ROOT, UI_RELPATH) + fmt.Println("ztnc Exited") + }, nil) // This will serve the index.html file embedded in the binary http.Handle(UI_RELPATH+"/", uiRouter.Handler()) diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/README.txt b/example/plugins/ztnc/mod/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/example/plugins/ztnc/mod/zoraxy_plugin/README.txt @@ -0,0 +1,19 @@ +# Zoraxy Plugin + +## Overview +This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components. + +## Instructions + +1. **Copy the Module:** + - Copy the entire `zoraxy_plugin` module to your plugin mod folder. + +2. **Include the Structure:** + - Ensure that you maintain the directory structure and file organization as provided in this module. + +3. **Modify as Needed:** + - Customize the copied module to implement the desired functionality for your plugin. + +## Directory Structure + zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup + embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages \ No newline at end of file diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go index d9b3fde..c529e99 100644 --- a/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go +++ b/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go @@ -6,6 +6,7 @@ import ( "io/fs" "net/http" "net/url" + "os" "strings" "time" ) @@ -15,6 +16,8 @@ type PluginUiRouter struct { TargetFs *embed.FS //The embed.FS where the UI files are stored TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + + terminateHandler func() //The handler to be called when the plugin is terminated } // NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS @@ -104,3 +107,22 @@ func (p *PluginUiRouter) Handler() http.Handler { p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) }) } + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go index 1691591..b316e6d 100644 --- a/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go +++ b/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go @@ -4,9 +4,7 @@ import ( "encoding/json" "fmt" "os" - "os/signal" "strings" - "syscall" ) /* @@ -79,9 +77,8 @@ type IntroSpect struct { 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 + GlobalCapturePaths []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) /* @@ -90,20 +87,9 @@ type IntroSpect struct { 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) + AlwaysCapturePaths []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 @@ -186,25 +172,3 @@ func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { ServeIntroSpect(pluginSpect) return RecvConfigureSpec() } - -/* - -Shutdown handler - -This function will register a shutdown handler for the plugin -The shutdown callback will be called when the plugin is shutting down -You can use this to clean up resources like closing database connections -*/ - -func RegisterShutdownHandler(shutdownCallback func()) { - // Set up a channel to receive OS signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Start a goroutine to listen for signals - go func() { - <-sigChan - shutdownCallback() - os.Exit(0) - }() -} diff --git a/src/def.go b/src/def.go index b03fd8d..9e355c9 100644 --- a/src/def.go +++ b/src/def.go @@ -44,7 +44,7 @@ const ( /* Build Constants */ SYSTEM_NAME = "Zoraxy" SYSTEM_VERSION = "3.1.9" - DEVELOPMENT_BUILD = true /* Development: Set to false to use embedded web fs */ + DEVELOPMENT_BUILD = false /* Development: Set to false to use embedded web fs */ /* System Constants */ TMP_FOLDER = "./tmp" diff --git a/src/main.go b/src/main.go index 67b71bf..18dd086 100644 --- a/src/main.go +++ b/src/main.go @@ -50,7 +50,7 @@ import ( /* SIGTERM handler, do shutdown sequences before closing */ func SetupCloseHandler() { c := make(chan os.Signal, 2) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) + signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) go func() { <-c ShutdownSeq() diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go index 7c7ebbf..88c5b23 100644 --- a/src/mod/plugins/lifecycle.go +++ b/src/mod/plugins/lifecycle.go @@ -4,12 +4,14 @@ import ( "encoding/json" "errors" "io" + "net/http" "net/url" - "os" "os/exec" "path/filepath" + "runtime" "strconv" "strings" + "syscall" "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" @@ -146,20 +148,55 @@ func (m *Manager) StopPlugin(pluginID string) error { } 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) + var err error + + //Make a GET request to plugin ui path /term to gracefully stop the plugin + if thisPlugin.uiProxy != nil { + requestURI := "http://127.0.0.1:" + strconv.Itoa(thisPlugin.AssignedPort) + "/" + thisPlugin.Spec.UIPath + "/term" + resp, err := http.Get(requestURI) + if err != nil { + //Plugin do not support termination request, do it the hard way + m.Log("Plugin "+thisPlugin.Spec.ID+" termination request failed. Force shutting down", nil) + } else { + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusNotFound { + m.Log("Plugin "+thisPlugin.Spec.ID+" does not support termination request", nil) + } else { + m.Log("Plugin "+thisPlugin.Spec.ID+" termination request returned status: "+resp.Status, nil) + } + + } + } + } + + if runtime.GOOS == "windows" && thisPlugin.process != nil { + //There is no SIGTERM in windows, kill the process directly + time.Sleep(300 * time.Millisecond) + thisPlugin.process.Process.Kill() + } else { + //Send SIGTERM to the plugin process, if it is still running + err = thisPlugin.process.Process.Signal(syscall.SIGTERM) + if err != nil { + m.Log("Failed to send Interrupt signal to plugin "+thisPlugin.Spec.Name+": "+err.Error(), nil) + } + + //Wait for the plugin to stop + for range 5 { + time.Sleep(1 * time.Second) + if thisPlugin.process.ProcessState != nil && thisPlugin.process.ProcessState.Exited() { + m.Log("Plugin "+thisPlugin.Spec.Name+" background process stopped", nil) + break + } + } 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) } + } - //Remove the UI proxy - thisPlugin.uiProxy = nil - }() + //Remove the UI proxy + thisPlugin.uiProxy = nil plugin.(*Plugin).Enabled = false return nil } diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index cc51cd0..5be4af4 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -112,7 +112,7 @@ func (m *Manager) GetPluginPreviousEnableState(pluginID string) bool { // ListLoadedPlugins returns a list of loaded plugins func (m *Manager) ListLoadedPlugins() ([]*Plugin, error) { - var plugins []*Plugin + var plugins []*Plugin = []*Plugin{} m.LoadedPlugins.Range(func(key, value interface{}) bool { plugin := value.(*Plugin) plugins = append(plugins, plugin) diff --git a/src/mod/plugins/zoraxy_plugin/embed_webserver.go b/src/mod/plugins/zoraxy_plugin/embed_webserver.go index d9b3fde..c529e99 100644 --- a/src/mod/plugins/zoraxy_plugin/embed_webserver.go +++ b/src/mod/plugins/zoraxy_plugin/embed_webserver.go @@ -6,6 +6,7 @@ import ( "io/fs" "net/http" "net/url" + "os" "strings" "time" ) @@ -15,6 +16,8 @@ type PluginUiRouter struct { TargetFs *embed.FS //The embed.FS where the UI files are stored TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + + terminateHandler func() //The handler to be called when the plugin is terminated } // NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS @@ -104,3 +107,22 @@ func (p *PluginUiRouter) Handler() http.Handler { p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) }) } + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} diff --git a/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go index f3865ea..b316e6d 100644 --- a/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go +++ b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go @@ -4,9 +4,7 @@ import ( "encoding/json" "fmt" "os" - "os/signal" "strings" - "syscall" ) /* @@ -174,25 +172,3 @@ func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { ServeIntroSpect(pluginSpect) return RecvConfigureSpec() } - -/* - -Shutdown handler - -This function will register a shutdown handler for the plugin -The shutdown callback will be called when the plugin is shutting down -You can use this to clean up resources like closing database connections -*/ - -func RegisterShutdownHandler(shutdownCallback func()) { - // Set up a channel to receive OS signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Start a goroutine to listen for signals - go func() { - <-sigChan - shutdownCallback() - os.Exit(0) - }() -} diff --git a/src/web/components/plugincontext.html b/src/web/components/plugincontext.html index 3e49a52..f8e2bde 100644 --- a/src/web/components/plugincontext.html +++ b/src/web/components/plugincontext.html @@ -4,13 +4,23 @@ diff --git a/src/web/index.html b/src/web/index.html index 9498308..8c71281 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -99,7 +99,7 @@ - No Installed Plugins + No Plugins Installed