diff --git a/.gitignore b/.gitignore index 8cdd224..36003b0 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,8 @@ src/log/ # dev-tags /Dockerfile -/Entrypoint.sh \ No newline at end of file +/Entrypoint.sh + +# plugins +example/plugins/ztnc/ztnc.db +example/plugins/ztnc/authtoken.secret diff --git a/example/plugins/build_all.sh b/example/plugins/build_all.sh new file mode 100644 index 0000000..76d3792 --- /dev/null +++ b/example/plugins/build_all.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Iterate over all directories in the current directory +for dir in */; do + if [ -d "$dir" ]; then + echo "Processing directory: $dir" + cd "$dir" + + # Execute go mod tidy + echo "Running go mod tidy in $dir" + go mod tidy + + # Execute go build + echo "Running go build in $dir" + go build + + # Return to the parent directory + cd .. + fi +done + +echo "Build process completed for all directories." \ No newline at end of file diff --git a/example/plugins/debugger/go.mod b/example/plugins/debugger/go.mod new file mode 100644 index 0000000..ec640ed --- /dev/null +++ b/example/plugins/debugger/go.mod @@ -0,0 +1,3 @@ +module aroz.org/zoraxy/debugger + +go 1.23.6 diff --git a/example/plugins/debugger/main.go b/example/plugins/debugger/main.go new file mode 100644 index 0000000..4e1b15d --- /dev/null +++ b/example/plugins/debugger/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + + plugin "aroz.org/zoraxy/debugger/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.debugger" + UI_PATH = "/debug" +) + +func main() { + // Serve the plugin intro spect + // This will print the plugin intro spect and exit if the -introspect flag is provided + runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: "org.aroz.zoraxy.debugger", + Name: "Plugin Debugger", + Author: "aroz.org", + AuthorContact: "https://aroz.org", + Description: "A debugger for Zoraxy <-> plugin communication pipeline", + URL: "https://zoraxy.aroz.org", + Type: plugin.PluginType_Router, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + GlobalCapturePaths: []plugin.CaptureRule{ + { + CapturePath: "/debug_test", //Capture all traffic of all HTTP proxy rule + IncludeSubPaths: true, + }, + }, + GlobalCaptureIngress: "", + AlwaysCapturePaths: []plugin.CaptureRule{}, + AlwaysCaptureIngress: "", + + UIPath: UI_PATH, + + /* + SubscriptionPath: "/subept", + SubscriptionsEvents: []plugin.SubscriptionEvent{ + */ + }) + if err != nil { + //Terminate or enter standalone mode here + panic(err) + } + + // Register the shutdown handler + plugin.RegisterShutdownHandler(func() { + // Do cleanup here if needed + fmt.Println("Debugger Terminated") + }) + + http.HandleFunc(UI_PATH+"/", RenderDebugUI) + http.HandleFunc("/gcapture", HandleIngressCapture) + fmt.Println("Debugger started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port)) + http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) +} + +// Handle the captured request +func HandleIngressCapture(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Capture request received") + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("This request is captured by the debugger")) +} diff --git a/example/plugins/debugger/mod/zoraxy_plugin/README.txt b/example/plugins/debugger/mod/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/example/plugins/debugger/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/debugger/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..d9b3fde --- /dev/null +++ b/example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,106 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + 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 +} + +// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + 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 { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} diff --git a/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..f3865ea --- /dev/null +++ b/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,198 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "syscall" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type CaptureRule struct { + CapturePath string `json:"capture_path"` + IncludeSubPaths bool `json:"include_sub_paths"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Global Capture Settings + + 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 + + */ + 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) + + /* + Always Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + these always applies + */ + 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) + + /* 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 + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + 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 + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + 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() +} + +/* + +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/debugger/ui_info.go b/example/plugins/debugger/ui_info.go new file mode 100644 index 0000000..6c53aeb --- /dev/null +++ b/example/plugins/debugger/ui_info.go @@ -0,0 +1,26 @@ +package main + +import ( + _ "embed" + "fmt" + "net/http" + "sort" +) + +// Render the debug UI +func RenderDebugUI(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "**Plugin UI Debug Interface**\n\n[Recv Headers] \n") + + headerKeys := make([]string, 0, len(r.Header)) + for name := range r.Header { + headerKeys = append(headerKeys, name) + } + sort.Strings(headerKeys) + for _, name := range headerKeys { + values := r.Header[name] + for _, value := range values { + fmt.Fprintf(w, "%s: %s\n", name, value) + } + } + w.Header().Set("Content-Type", "text/html") +} diff --git a/example/plugins/helloworld/go.mod b/example/plugins/helloworld/go.mod new file mode 100644 index 0000000..acfde6b --- /dev/null +++ b/example/plugins/helloworld/go.mod @@ -0,0 +1,3 @@ +module example.com/zoraxy/helloworld + +go 1.23.6 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/main.go b/example/plugins/helloworld/main.go new file mode 100644 index 0000000..74188cf --- /dev/null +++ b/example/plugins/helloworld/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "embed" + _ "embed" + "fmt" + "net/http" + "strconv" + + plugin "example.com/zoraxy/helloworld/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "com.example.helloworld" + UI_PATH = "/" + WEB_ROOT = "/www" +) + +//go:embed www/* +var content embed.FS + +func main() { + // Serve the plugin intro spect + // This will print the plugin intro spect and exit if the -introspect flag is provided + runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: "com.example.helloworld", + Name: "Hello World Plugin", + Author: "foobar", + AuthorContact: "admin@example.com", + Description: "A simple hello world plugin", + URL: "https://example.com", + Type: plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + // As this is a utility plugin, we don't need to capture any traffic + // but only serve the UI, so we set the UI (relative to the plugin path) to "/" + UIPath: UI_PATH, + }) + if err != nil { + //Terminate or enter standalone mode here + panic(err) + } + + // 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") + }, 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)) + err = http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) + if err != nil { + panic(err) + } + +} diff --git a/example/plugins/helloworld/www/index.html b/example/plugins/helloworld/www/index.html new file mode 100644 index 0000000..bc48067 --- /dev/null +++ b/example/plugins/helloworld/www/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + Hello World + + + + + + +
+

Hello World

+

Welcome to your first Zoraxy plugin

+
+ + \ No newline at end of file diff --git a/example/plugins/helloworld/zoraxy_plugin/README.txt b/example/plugins/helloworld/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/example/plugins/helloworld/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/helloworld/zoraxy_plugin/embed_webserver.go b/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..c529e99 --- /dev/null +++ b/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,128 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + 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 +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + 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 { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + 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 new file mode 100644 index 0000000..b316e6d --- /dev/null +++ b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,174 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type CaptureRule struct { + CapturePath string `json:"capture_path"` + IncludeSubPaths bool `json:"include_sub_paths"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Global Capture Settings + + 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 + + */ + 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) + + /* + Always Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + these always applies + */ + 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) + + /* 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 + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + 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 + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + 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() +} diff --git a/example/plugins/ztnc/README.md b/example/plugins/ztnc/README.md new file mode 100644 index 0000000..a942efd --- /dev/null +++ b/example/plugins/ztnc/README.md @@ -0,0 +1,11 @@ +## Global Area Network Plugin + +This plugin implements a user interface for ZeroTier Network Controller in Zoraxy + + + + + +## License + +AGPL \ No newline at end of file diff --git a/example/plugins/ztnc/go.mod b/example/plugins/ztnc/go.mod new file mode 100644 index 0000000..aa0cc97 --- /dev/null +++ b/example/plugins/ztnc/go.mod @@ -0,0 +1,11 @@ +module aroz.org/zoraxy/ztnc + +go 1.23.6 + +require ( + github.com/boltdb/bolt v1.3.1 + github.com/syndtr/goleveldb v1.0.0 + golang.org/x/sys v0.30.0 +) + +require github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect diff --git a/example/plugins/ztnc/go.sum b/example/plugins/ztnc/go.sum new file mode 100644 index 0000000..875979f --- /dev/null +++ b/example/plugins/ztnc/go.sum @@ -0,0 +1,30 @@ +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/example/plugins/ztnc/icon.png b/example/plugins/ztnc/icon.png new file mode 100644 index 0000000..e19e043 Binary files /dev/null and b/example/plugins/ztnc/icon.png differ diff --git a/example/plugins/ztnc/icon.psd b/example/plugins/ztnc/icon.psd new file mode 100644 index 0000000..e8c221b Binary files /dev/null and b/example/plugins/ztnc/icon.psd differ diff --git a/example/plugins/ztnc/main.go b/example/plugins/ztnc/main.go new file mode 100644 index 0000000..ee96033 --- /dev/null +++ b/example/plugins/ztnc/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + + "embed" + + "aroz.org/zoraxy/ztnc/mod/database" + "aroz.org/zoraxy/ztnc/mod/ganserv" + plugin "aroz.org/zoraxy/ztnc/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.ztnc" + UI_RELPATH = "/ui" + EMBED_FS_ROOT = "/web" + DB_FILE_PATH = "ztnc.db" + AUTH_TOKEN_PATH = "./authtoken.secret" +) + +//go:embed web/* +var content embed.FS + +var ( + sysdb *database.Database + ganManager *ganserv.NetworkManager +) + +func main() { + // Serve the plugin intro spect + runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: PLUGIN_ID, + Name: "ztnc", + Author: "aroz.org", + AuthorContact: "zoraxy.aroz.org", + Description: "UI for ZeroTier Network Controller", + URL: "https://zoraxy.aroz.org", + Type: plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + // As this is a utility plugin, we don't need to capture any traffic + // but only serve the UI, so we set the UI (relative to the plugin path) to "/ui/" to match the HTTP Handler + UIPath: UI_RELPATH, + }) + if err != nil { + //Terminate or enter standalone mode here + 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 + uiRouter.RegisterTerminateHandler(func() { + // Do cleanup here if needed + if sysdb != nil { + sysdb.Close() + } + fmt.Println("ztnc Exited") + }, nil) + + // This will serve the index.html file embedded in the binary + http.Handle(UI_RELPATH+"/", uiRouter.Handler()) + + // Start the GAN Network Controller + err = startGanNetworkController() + if err != nil { + panic(err) + } + + // Initiate the API endpoints + initApiEndpoints() + + // Start the HTTP server, only listen to loopback interface + fmt.Println("Plugin UI server started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port) + UI_RELPATH) + http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) +} diff --git a/example/plugins/ztnc/mod/database/database.go b/example/plugins/ztnc/mod/database/database.go new file mode 100644 index 0000000..bf82ae0 --- /dev/null +++ b/example/plugins/ztnc/mod/database/database.go @@ -0,0 +1,146 @@ +package database + +/* + ArOZ Online Database Access Module + author: tobychui + + This is an improved Object oriented base solution to the original + aroz online database script. +*/ + +import ( + "log" + "runtime" + + "aroz.org/zoraxy/ztnc/mod/database/dbinc" +) + +type Database struct { + Db interface{} //This will be nil on openwrt, leveldb.DB on x64 platforms or bolt.DB on other platforms + BackendType dbinc.BackendType + Backend dbinc.Backend +} + +func NewDatabase(dbfile string, backendType dbinc.BackendType) (*Database, error) { + if runtime.GOARCH == "riscv64" { + log.Println("RISCV hardware detected, ignoring the backend type and using FS emulated database") + } + return newDatabase(dbfile, backendType) +} + +// Get the recommended backend type for the current system +func GetRecommendedBackendType() dbinc.BackendType { + //Check if the system is running on RISCV hardware + if runtime.GOARCH == "riscv64" { + //RISCV hardware, currently only support FS emulated database + return dbinc.BackendFSOnly + } else if runtime.GOOS == "windows" || (runtime.GOOS == "linux" && runtime.GOARCH == "amd64") { + //Powerful hardware + return dbinc.BackendBoltDB + //return dbinc.BackendLevelDB + } + + //Default to BoltDB, the safest option + return dbinc.BackendBoltDB +} + +/* + Create / Drop a table + Usage: + err := sysdb.NewTable("MyTable") + err := sysdb.DropTable("MyTable") +*/ + +// Create a new table +func (d *Database) NewTable(tableName string) error { + return d.newTable(tableName) +} + +// Check is table exists +func (d *Database) TableExists(tableName string) bool { + return d.tableExists(tableName) +} + +// Drop the given table +func (d *Database) DropTable(tableName string) error { + return d.dropTable(tableName) +} + +/* +Write to database with given tablename and key. Example Usage: + + type demo struct{ + content string + } + + thisDemo := demo{ + content: "Hello World", + } + +err := sysdb.Write("MyTable", "username/message",thisDemo); +*/ +func (d *Database) Write(tableName string, key string, value interface{}) error { + return d.write(tableName, key, value) +} + +/* + Read from database and assign the content to a given datatype. Example Usage: + + type demo struct{ + content string + } + thisDemo := new(demo) + err := sysdb.Read("MyTable", "username/message",&thisDemo); +*/ + +func (d *Database) Read(tableName string, key string, assignee interface{}) error { + return d.read(tableName, key, assignee) +} + +/* +Check if a key exists in the database table given tablename and key + + if sysdb.KeyExists("MyTable", "username/message"){ + log.Println("Key exists") + } +*/ +func (d *Database) KeyExists(tableName string, key string) bool { + return d.keyExists(tableName, key) +} + +/* +Delete a value from the database table given tablename and key + +err := sysdb.Delete("MyTable", "username/message"); +*/ +func (d *Database) Delete(tableName string, key string) error { + return d.delete(tableName, key) +} + +/* + //List table example usage + //Assume the value is stored as a struct named "groupstruct" + + entries, err := sysdb.ListTable("test") + if err != nil { + panic(err) + } + for _, keypairs := range entries{ + log.Println(string(keypairs[0])) + group := new(groupstruct) + json.Unmarshal(keypairs[1], &group) + log.Println(group); + } + +*/ + +func (d *Database) ListTable(tableName string) ([][][]byte, error) { + return d.listTable(tableName) +} + +/* +Close the database connection +*/ +func (d *Database) Close() { + d.close() +} diff --git a/example/plugins/ztnc/mod/database/database_core.go b/example/plugins/ztnc/mod/database/database_core.go new file mode 100644 index 0000000..347b000 --- /dev/null +++ b/example/plugins/ztnc/mod/database/database_core.go @@ -0,0 +1,70 @@ +//go:build !mipsle && !riscv64 +// +build !mipsle,!riscv64 + +package database + +import ( + "errors" + + "aroz.org/zoraxy/ztnc/mod/database/dbbolt" + "aroz.org/zoraxy/ztnc/mod/database/dbinc" + "aroz.org/zoraxy/ztnc/mod/database/dbleveldb" +) + +func newDatabase(dbfile string, backendType dbinc.BackendType) (*Database, error) { + if backendType == dbinc.BackendFSOnly { + return nil, errors.New("Unsupported backend type for this platform") + } + + if backendType == dbinc.BackendLevelDB { + db, err := dbleveldb.NewDB(dbfile) + return &Database{ + Db: nil, + BackendType: backendType, + Backend: db, + }, err + } + + db, err := dbbolt.NewBoltDatabase(dbfile) + return &Database{ + Db: nil, + BackendType: backendType, + Backend: db, + }, err +} + +func (d *Database) newTable(tableName string) error { + return d.Backend.NewTable(tableName) +} + +func (d *Database) tableExists(tableName string) bool { + return d.Backend.TableExists(tableName) +} + +func (d *Database) dropTable(tableName string) error { + return d.Backend.DropTable(tableName) +} + +func (d *Database) write(tableName string, key string, value interface{}) error { + return d.Backend.Write(tableName, key, value) +} + +func (d *Database) read(tableName string, key string, assignee interface{}) error { + return d.Backend.Read(tableName, key, assignee) +} + +func (d *Database) keyExists(tableName string, key string) bool { + return d.Backend.KeyExists(tableName, key) +} + +func (d *Database) delete(tableName string, key string) error { + return d.Backend.Delete(tableName, key) +} + +func (d *Database) listTable(tableName string) ([][][]byte, error) { + return d.Backend.ListTable(tableName) +} + +func (d *Database) close() { + d.Backend.Close() +} diff --git a/example/plugins/ztnc/mod/database/database_openwrt.go b/example/plugins/ztnc/mod/database/database_openwrt.go new file mode 100644 index 0000000..fd3d8b2 --- /dev/null +++ b/example/plugins/ztnc/mod/database/database_openwrt.go @@ -0,0 +1,196 @@ +//go:build mipsle || riscv64 +// +build mipsle riscv64 + +package database + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + "strings" + + "aroz.org/zoraxy/ztnc/mod/database/dbinc" +) + +/* + OpenWRT or RISCV backend + + For OpenWRT or RISCV platform, we will use the filesystem as the database backend + as boltdb or leveldb is not supported on these platforms, including boltDB and LevelDB + in conditional compilation will create a build error on these platforms +*/ + +func newDatabase(dbfile string, backendType dbinc.BackendType) (*Database, error) { + dbRootPath := filepath.ToSlash(filepath.Clean(dbfile)) + dbRootPath = "fsdb/" + dbRootPath + err := os.MkdirAll(dbRootPath, 0755) + if err != nil { + return nil, err + } + + log.Println("Filesystem Emulated Key-value Database Service Started: " + dbRootPath) + return &Database{ + Db: dbRootPath, + BackendType: dbinc.BackendFSOnly, + Backend: nil, + }, nil +} + +func (d *Database) dump(filename string) ([]string, error) { + //Get all file objects from root + rootfiles, err := filepath.Glob(filepath.Join(d.Db.(string), "/*")) + if err != nil { + return []string{}, err + } + + //Filter out the folders + rootFolders := []string{} + for _, file := range rootfiles { + if !isDirectory(file) { + rootFolders = append(rootFolders, filepath.Base(file)) + } + } + + return rootFolders, nil +} + +func (d *Database) newTable(tableName string) error { + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + if !fileExists(tablePath) { + return os.MkdirAll(tablePath, 0755) + } + return nil +} + +func (d *Database) tableExists(tableName string) bool { + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + if _, err := os.Stat(tablePath); errors.Is(err, os.ErrNotExist) { + return false + } + + if !isDirectory(tablePath) { + return false + } + + return true +} + +func (d *Database) dropTable(tableName string) error { + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + if d.tableExists(tableName) { + return os.RemoveAll(tablePath) + } else { + return errors.New("table not exists") + } + +} + +func (d *Database) write(tableName string, key string, value interface{}) error { + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + js, err := json.Marshal(value) + if err != nil { + return err + } + + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + + return os.WriteFile(filepath.Join(tablePath, key+".entry"), js, 0755) +} + +func (d *Database) read(tableName string, key string, assignee interface{}) error { + if !d.keyExists(tableName, key) { + return errors.New("key not exists") + } + + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entryPath := filepath.Join(tablePath, key+".entry") + content, err := os.ReadFile(entryPath) + if err != nil { + return err + } + + err = json.Unmarshal(content, &assignee) + return err +} + +func (d *Database) keyExists(tableName string, key string) bool { + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entryPath := filepath.Join(tablePath, key+".entry") + return fileExists(entryPath) +} + +func (d *Database) delete(tableName string, key string) error { + + if !d.keyExists(tableName, key) { + return errors.New("key not exists") + } + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entryPath := filepath.Join(tablePath, key+".entry") + + return os.Remove(entryPath) +} + +func (d *Database) listTable(tableName string) ([][][]byte, error) { + if !d.tableExists(tableName) { + return [][][]byte{}, errors.New("table not exists") + } + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entries, err := filepath.Glob(filepath.Join(tablePath, "/*.entry")) + if err != nil { + return [][][]byte{}, err + } + + var results [][][]byte = [][][]byte{} + for _, entry := range entries { + if !isDirectory(entry) { + //Read it + key := filepath.Base(entry) + key = strings.TrimSuffix(key, filepath.Ext(key)) + key = strings.ReplaceAll(key, "-SLASH_SIGN-", "/") + + bkey := []byte(key) + bval := []byte("") + c, err := os.ReadFile(entry) + if err != nil { + break + } + + bval = c + results = append(results, [][]byte{bkey, bval}) + } + } + return results, nil +} + +func (d *Database) close() { + //Nothing to close as it is file system +} + +func isDirectory(path string) bool { + fileInfo, err := os.Stat(path) + if err != nil { + return false + } + + return fileInfo.IsDir() +} + +func fileExists(name string) bool { + _, err := os.Stat(name) + if err == nil { + return true + } + if errors.Is(err, os.ErrNotExist) { + return false + } + return false +} diff --git a/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go b/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go new file mode 100644 index 0000000..8cf7ec0 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go @@ -0,0 +1,141 @@ +package dbbolt + +import ( + "encoding/json" + "errors" + + "github.com/boltdb/bolt" +) + +type Database struct { + Db interface{} //This is the bolt database object +} + +func NewBoltDatabase(dbfile string) (*Database, error) { + db, err := bolt.Open(dbfile, 0600, nil) + if err != nil { + return nil, err + } + + return &Database{ + Db: db, + }, err +} + +// Create a new table +func (d *Database) NewTable(tableName string) error { + err := d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(tableName)) + if err != nil { + return err + } + return nil + }) + + return err +} + +// Check is table exists +func (d *Database) TableExists(tableName string) bool { + return d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + if b == nil { + return errors.New("table not exists") + } + return nil + }) == nil +} + +// Drop the given table +func (d *Database) DropTable(tableName string) error { + err := d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + err := tx.DeleteBucket([]byte(tableName)) + if err != nil { + return err + } + return nil + }) + return err +} + +// Write to table +func (d *Database) Write(tableName string, key string, value interface{}) error { + jsonString, err := json.Marshal(value) + if err != nil { + return err + } + err = d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(tableName)) + if err != nil { + return err + } + b := tx.Bucket([]byte(tableName)) + err = b.Put([]byte(key), jsonString) + return err + }) + return err +} + +func (d *Database) Read(tableName string, key string, assignee interface{}) error { + err := d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + v := b.Get([]byte(key)) + json.Unmarshal(v, &assignee) + return nil + }) + return err +} + +func (d *Database) KeyExists(tableName string, key string) bool { + resultIsNil := false + if !d.TableExists(tableName) { + //Table not exists. Do not proceed accessing key + //log.Println("[DB] ERROR: Requesting key from table that didn't exist!!!") + return false + } + err := d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + v := b.Get([]byte(key)) + if v == nil { + resultIsNil = true + } + return nil + }) + + if err != nil { + return false + } else { + if resultIsNil { + return false + } else { + return true + } + } +} + +func (d *Database) Delete(tableName string, key string) error { + err := d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + tx.Bucket([]byte(tableName)).Delete([]byte(key)) + return nil + }) + + return err +} + +func (d *Database) ListTable(tableName string) ([][][]byte, error) { + var results [][][]byte + err := d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + c := b.Cursor() + + for k, v := c.First(); k != nil; k, v = c.Next() { + results = append(results, [][]byte{k, v}) + } + return nil + }) + return results, err +} + +func (d *Database) Close() { + d.Db.(*bolt.DB).Close() +} diff --git a/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go b/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go new file mode 100644 index 0000000..05e708a --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go @@ -0,0 +1,67 @@ +package dbbolt_test + +import ( + "os" + "testing" + + "aroz.org/zoraxy/ztnc/mod/database/dbbolt" +) + +func TestNewBoltDatabase(t *testing.T) { + dbfile := "test.db" + defer os.Remove(dbfile) + + db, err := dbbolt.NewBoltDatabase(dbfile) + if err != nil { + t.Fatalf("Failed to create new Bolt database: %v", err) + } + defer db.Close() + + if db.Db == nil { + t.Fatalf("Expected non-nil database object") + } +} + +func TestNewTable(t *testing.T) { + dbfile := "test.db" + defer os.Remove(dbfile) + + db, err := dbbolt.NewBoltDatabase(dbfile) + if err != nil { + t.Fatalf("Failed to create new Bolt database: %v", err) + } + defer db.Close() + + err = db.NewTable("testTable") + if err != nil { + t.Fatalf("Failed to create new table: %v", err) + } +} + +func TestTableExists(t *testing.T) { + dbfile := "test.db" + defer os.Remove(dbfile) + + db, err := dbbolt.NewBoltDatabase(dbfile) + if err != nil { + t.Fatalf("Failed to create new Bolt database: %v", err) + } + defer db.Close() + + tableName := "testTable" + err = db.NewTable(tableName) + if err != nil { + t.Fatalf("Failed to create new table: %v", err) + } + + exists := db.TableExists(tableName) + if !exists { + t.Fatalf("Expected table %s to exist", tableName) + } + + nonExistentTable := "nonExistentTable" + exists = db.TableExists(nonExistentTable) + if exists { + t.Fatalf("Expected table %s to not exist", nonExistentTable) + } +} diff --git a/example/plugins/ztnc/mod/database/dbinc/dbinc.go b/example/plugins/ztnc/mod/database/dbinc/dbinc.go new file mode 100644 index 0000000..8e60ba0 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbinc/dbinc.go @@ -0,0 +1,39 @@ +package dbinc + +/* + dbinc is the interface for all database backend +*/ +type BackendType int + +const ( + BackendBoltDB BackendType = iota //Default backend + BackendFSOnly //OpenWRT or RISCV backend + BackendLevelDB //LevelDB backend + + BackEndAuto = BackendBoltDB +) + +type Backend interface { + NewTable(tableName string) error + TableExists(tableName string) bool + DropTable(tableName string) error + Write(tableName string, key string, value interface{}) error + Read(tableName string, key string, assignee interface{}) error + KeyExists(tableName string, key string) bool + Delete(tableName string, key string) error + ListTable(tableName string) ([][][]byte, error) + Close() +} + +func (b BackendType) String() string { + switch b { + case BackendBoltDB: + return "BoltDB" + case BackendFSOnly: + return "File System Emulated Key-Value Store" + case BackendLevelDB: + return "LevelDB" + default: + return "Unknown" + } +} diff --git a/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go new file mode 100644 index 0000000..59b9667 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go @@ -0,0 +1,152 @@ +package dbleveldb + +import ( + "encoding/json" + "log" + "path/filepath" + "strings" + "sync" + "time" + + "aroz.org/zoraxy/ztnc/mod/database/dbinc" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" +) + +// Ensure the DB struct implements the Backend interface +var _ dbinc.Backend = (*DB)(nil) + +type DB struct { + db *leveldb.DB + Table sync.Map //For emulating table creation + batch leveldb.Batch //Batch write + writeFlushTicker *time.Ticker //Ticker for flushing data into disk + writeFlushStop chan bool //Stop channel for write flush ticker +} + +func NewDB(path string) (*DB, error) { + //If the path is not a directory (e.g. /tmp/dbfile.db), convert the filename to directory + if filepath.Ext(path) != "" { + path = strings.ReplaceAll(path, ".", "_") + } + + db, err := leveldb.OpenFile(path, nil) + if err != nil { + return nil, err + } + + thisDB := &DB{ + db: db, + Table: sync.Map{}, + batch: leveldb.Batch{}, + } + + //Create a ticker to flush data into disk every 1 seconds + writeFlushTicker := time.NewTicker(1 * time.Second) + writeFlushStop := make(chan bool) + go func() { + for { + select { + case <-writeFlushTicker.C: + if thisDB.batch.Len() == 0 { + //No flushing needed + continue + } + err = db.Write(&thisDB.batch, nil) + if err != nil { + log.Println("[LevelDB] Failed to flush data into disk: ", err) + } + thisDB.batch.Reset() + case <-writeFlushStop: + return + } + } + }() + + thisDB.writeFlushTicker = writeFlushTicker + thisDB.writeFlushStop = writeFlushStop + + return thisDB, nil +} + +func (d *DB) NewTable(tableName string) error { + //Create a table entry in the sync.Map + d.Table.Store(tableName, true) + return nil +} + +func (d *DB) TableExists(tableName string) bool { + _, ok := d.Table.Load(tableName) + return ok +} + +func (d *DB) DropTable(tableName string) error { + d.Table.Delete(tableName) + iter := d.db.NewIterator(nil, nil) + defer iter.Release() + + for iter.Next() { + key := iter.Key() + if filepath.Dir(string(key)) == tableName { + err := d.db.Delete(key, nil) + if err != nil { + return err + } + } + } + + return nil +} + +func (d *DB) Write(tableName string, key string, value interface{}) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + d.batch.Put([]byte(filepath.ToSlash(filepath.Join(tableName, key))), data) + return nil +} + +func (d *DB) Read(tableName string, key string, assignee interface{}) error { + data, err := d.db.Get([]byte(filepath.ToSlash(filepath.Join(tableName, key))), nil) + if err != nil { + return err + } + return json.Unmarshal(data, assignee) +} + +func (d *DB) KeyExists(tableName string, key string) bool { + _, err := d.db.Get([]byte(filepath.ToSlash(filepath.Join(tableName, key))), nil) + return err == nil +} + +func (d *DB) Delete(tableName string, key string) error { + return d.db.Delete([]byte(filepath.ToSlash(filepath.Join(tableName, key))), nil) +} + +func (d *DB) ListTable(tableName string) ([][][]byte, error) { + iter := d.db.NewIterator(util.BytesPrefix([]byte(tableName+"/")), nil) + defer iter.Release() + + var result [][][]byte + for iter.Next() { + key := iter.Key() + //The key contains the table name as prefix. Trim it before returning + value := iter.Value() + result = append(result, [][]byte{[]byte(strings.TrimPrefix(string(key), tableName+"/")), value}) + } + + err := iter.Error() + if err != nil { + return nil, err + } + return result, nil +} + +func (d *DB) Close() { + //Write the remaining data in batch back into disk + d.writeFlushStop <- true + d.writeFlushTicker.Stop() + d.db.Write(&d.batch, nil) + d.db.Close() +} diff --git a/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go new file mode 100644 index 0000000..c091684 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go @@ -0,0 +1,141 @@ +package dbleveldb_test + +import ( + "os" + "testing" + + "aroz.org/zoraxy/ztnc/mod/database/dbleveldb" +) + +func TestNewDB(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() +} + +func TestNewTable(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + err = db.NewTable("testTable") + if err != nil { + t.Fatalf("Failed to create new table: %v", err) + } +} + +func TestTableExists(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + if !db.TableExists("testTable") { + t.Fatalf("Table should exist") + } +} + +func TestDropTable(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + err = db.DropTable("testTable") + if err != nil { + t.Fatalf("Failed to drop table: %v", err) + } + + if db.TableExists("testTable") { + t.Fatalf("Table should not exist") + } +} + +func TestWriteAndRead(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + err = db.Write("testTable", "testKey", "testValue") + if err != nil { + t.Fatalf("Failed to write to table: %v", err) + } + + var value string + err = db.Read("testTable", "testKey", &value) + if err != nil { + t.Fatalf("Failed to read from table: %v", err) + } + + if value != "testValue" { + t.Fatalf("Expected 'testValue', got '%v'", value) + } +} +func TestListTable(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + err = db.Write("testTable", "testKey1", "testValue1") + if err != nil { + t.Fatalf("Failed to write to table: %v", err) + } + err = db.Write("testTable", "testKey2", "testValue2") + if err != nil { + t.Fatalf("Failed to write to table: %v", err) + } + + result, err := db.ListTable("testTable") + if err != nil { + t.Fatalf("Failed to list table: %v", err) + } + + if len(result) != 2 { + t.Fatalf("Expected 2 entries, got %v", len(result)) + } + + expected := map[string]string{ + "testTable/testKey1": "\"testValue1\"", + "testTable/testKey2": "\"testValue2\"", + } + + for _, entry := range result { + key := string(entry[0]) + value := string(entry[1]) + if expected[key] != value { + t.Fatalf("Expected value '%v' for key '%v', got '%v'", expected[key], key, value) + } + } +} diff --git a/example/plugins/ztnc/mod/ganserv/authkey.go b/example/plugins/ztnc/mod/ganserv/authkey.go new file mode 100644 index 0000000..006e90d --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/authkey.go @@ -0,0 +1,80 @@ +package ganserv + +import ( + "errors" + "log" + "os" + "runtime" + "strings" +) + +func TryLoadorAskUserForAuthkey() (string, error) { + //Check for zt auth token + value, exists := os.LookupEnv("ZT_AUTH") + if !exists { + log.Println("Environment variable ZT_AUTH not defined. Trying to load authtoken from file.") + } else { + return value, nil + } + + authKey := "" + if runtime.GOOS == "windows" { + if isAdmin() { + //Read the secret file directly + b, err := os.ReadFile("C:\\ProgramData\\ZeroTier\\One\\authtoken.secret") + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = string(b) + } else { + log.Println("Unable to read authkey at C:\\ProgramData\\ZeroTier\\One\\authtoken.secret: ", err.Error()) + } + } else { + //Elavate the permission to admin + ak, err := readAuthTokenAsAdmin() + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = ak + } else { + log.Println("Unable to read authkey at C:\\ProgramData\\ZeroTier\\One\\authtoken.secret: ", err.Error()) + } + } + + } else if runtime.GOOS == "linux" { + if isAdmin() { + //Try to read from source using sudo + ak, err := readAuthTokenAsAdmin() + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = strings.TrimSpace(ak) + } else { + log.Println("Unable to read authkey at /var/lib/zerotier-one/authtoken.secret: ", err.Error()) + } + } else { + //Try read from source + b, err := os.ReadFile("/var/lib/zerotier-one/authtoken.secret") + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = string(b) + } else { + log.Println("Unable to read authkey at /var/lib/zerotier-one/authtoken.secret: ", err.Error()) + } + } + + } else if runtime.GOOS == "darwin" { + b, err := os.ReadFile("/Library/Application Support/ZeroTier/One/authtoken.secret") + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = string(b) + } else { + log.Println("Unable to read authkey at /Library/Application Support/ZeroTier/One/authtoken.secret ", err.Error()) + } + } + + authKey = strings.TrimSpace(authKey) + + if authKey == "" { + return "", errors.New("Unable to load authkey from file") + } + + return authKey, nil +} diff --git a/example/plugins/ztnc/mod/ganserv/authkeyLinux.go b/example/plugins/ztnc/mod/ganserv/authkeyLinux.go new file mode 100644 index 0000000..91ce202 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/authkeyLinux.go @@ -0,0 +1,37 @@ +//go:build linux +// +build linux + +package ganserv + +import ( + "os" + "os/exec" + "os/user" + "strings" + + "aroz.org/zoraxy/ztnc/mod/utils" +) + +func readAuthTokenAsAdmin() (string, error) { + if utils.FileExists("./conf/authtoken.secret") { + authKey, err := os.ReadFile("./conf/authtoken.secret") + if err == nil { + return strings.TrimSpace(string(authKey)), nil + } + } + + cmd := exec.Command("sudo", "cat", "/var/lib/zerotier-one/authtoken.secret") + output, err := cmd.Output() + if err != nil { + return "", err + } + return string(output), nil +} + +func isAdmin() bool { + currentUser, err := user.Current() + if err != nil { + return false + } + return currentUser.Username == "root" +} diff --git a/example/plugins/ztnc/mod/ganserv/authkeyWin.go b/example/plugins/ztnc/mod/ganserv/authkeyWin.go new file mode 100644 index 0000000..aa03e31 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/authkeyWin.go @@ -0,0 +1,73 @@ +//go:build windows +// +build windows + +package ganserv + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "aroz.org/zoraxy/ztnc/mod/utils" + "golang.org/x/sys/windows" +) + +// Use admin permission to read auth token on Windows +func readAuthTokenAsAdmin() (string, error) { + //Check if the previous startup already extracted the authkey + if utils.FileExists("./conf/authtoken.secret") { + authKey, err := os.ReadFile("./conf/authtoken.secret") + if err == nil { + return strings.TrimSpace(string(authKey)), nil + } + } + + verb := "runas" + exe := "cmd.exe" + cwd, _ := os.Getwd() + + output, _ := filepath.Abs(filepath.Join("./conf/", "authtoken.secret")) + os.WriteFile(output, []byte(""), 0775) + args := fmt.Sprintf("/C type \"C:\\ProgramData\\ZeroTier\\One\\authtoken.secret\" > \"" + output + "\"") + + verbPtr, _ := syscall.UTF16PtrFromString(verb) + exePtr, _ := syscall.UTF16PtrFromString(exe) + cwdPtr, _ := syscall.UTF16PtrFromString(cwd) + argPtr, _ := syscall.UTF16PtrFromString(args) + + var showCmd int32 = 1 //SW_NORMAL + + err := windows.ShellExecute(0, verbPtr, exePtr, argPtr, cwdPtr, showCmd) + if err != nil { + return "", err + } + + log.Println("Please click agree to allow access to ZeroTier authtoken from ProgramData") + retry := 0 + time.Sleep(3 * time.Second) + for !utils.FileExists("./conf/authtoken.secret") && retry < 10 { + time.Sleep(3 * time.Second) + log.Println("Waiting for ZeroTier authtoken extraction...") + retry++ + } + + authKey, err := os.ReadFile("./conf/authtoken.secret") + if err != nil { + return "", err + } + + return strings.TrimSpace(string(authKey)), nil +} + +// Check if admin on Windows +func isAdmin() bool { + _, err := os.Open("\\\\.\\PHYSICALDRIVE0") + if err != nil { + return false + } + return true +} diff --git a/example/plugins/ztnc/mod/ganserv/ganserv.go b/example/plugins/ztnc/mod/ganserv/ganserv.go new file mode 100644 index 0000000..f81e39b --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/ganserv.go @@ -0,0 +1,130 @@ +package ganserv + +import ( + "log" + "net" + + "aroz.org/zoraxy/ztnc/mod/database" +) + +/* + Global Area Network + Server side implementation + + This module do a few things to help manage + the system GANs + + - Provide DHCP assign to client + - Provide a list of connected nodes in the same VLAN + - Provide proxy of packet if the target VLAN is online but not reachable + + Also provide HTTP Handler functions for management + - Create Network + - Update Network Properties (Name / Desc) + - Delete Network + + - Authorize Node + - Deauthorize Node + - Set / Get Network Prefered Subnet Mask + - Handle Node ping +*/ + +type Node struct { + Auth bool //If the node is authorized in this network + ClientID string //The client ID + MAC string //The tap MAC this client is using + Name string //Name of the client in this network + Description string //Description text + ManagedIP net.IP //The IP address assigned by this network + LastSeen int64 //Last time it is seen from this host + ClientVersion string //Client application version + PublicIP net.IP //Public IP address as seen from this host +} + +type Network struct { + UID string //UUID of the network, must be a 16 char random ASCII string + Name string //Name of the network, ASCII only + Description string //Description of the network + CIDR string //The subnet masked use by this network + Nodes []*Node //The nodes currently attached in this network +} + +type NetworkManagerOptions struct { + Database *database.Database + AuthToken string + ApiPort int +} + +type NetworkMetaData struct { + Desc string +} + +type MemberMetaData struct { + Name string +} + +type NetworkManager struct { + authToken string + apiPort int + ControllerID string + option *NetworkManagerOptions + networksMetadata map[string]NetworkMetaData +} + +// Create a new GAN manager +func NewNetworkManager(option *NetworkManagerOptions) *NetworkManager { + option.Database.NewTable("ganserv") + + //Load network metadata + networkMeta := map[string]NetworkMetaData{} + if option.Database.KeyExists("ganserv", "networkmeta") { + option.Database.Read("ganserv", "networkmeta", &networkMeta) + } + + //Start the zerotier instance if not exists + + //Get controller info + instanceInfo, err := getControllerInfo(option.AuthToken, option.ApiPort) + if err != nil { + log.Println("ZeroTier connection failed: ", err.Error()) + return &NetworkManager{ + authToken: option.AuthToken, + apiPort: option.ApiPort, + ControllerID: "", + option: option, + networksMetadata: networkMeta, + } + } + + return &NetworkManager{ + authToken: option.AuthToken, + apiPort: option.ApiPort, + ControllerID: instanceInfo.Address, + option: option, + networksMetadata: networkMeta, + } +} + +func (m *NetworkManager) GetNetworkMetaData(netid string) *NetworkMetaData { + md, ok := m.networksMetadata[netid] + if !ok { + return &NetworkMetaData{} + } + + return &md +} + +func (m *NetworkManager) WriteNetworkMetaData(netid string, meta *NetworkMetaData) { + m.networksMetadata[netid] = *meta + m.option.Database.Write("ganserv", "networkmeta", m.networksMetadata) +} + +func (m *NetworkManager) GetMemberMetaData(netid string, memid string) *MemberMetaData { + thisMemberData := MemberMetaData{} + m.option.Database.Read("ganserv", "memberdata_"+netid+"_"+memid, &thisMemberData) + return &thisMemberData +} + +func (m *NetworkManager) WriteMemeberMetaData(netid string, memid string, meta *MemberMetaData) { + m.option.Database.Write("ganserv", "memberdata_"+netid+"_"+memid, meta) +} diff --git a/example/plugins/ztnc/mod/ganserv/handlers.go b/example/plugins/ztnc/mod/ganserv/handlers.go new file mode 100644 index 0000000..4ab76da --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/handlers.go @@ -0,0 +1,504 @@ +package ganserv + +import ( + "encoding/json" + "net" + "net/http" + "regexp" + "strings" + + "aroz.org/zoraxy/ztnc/mod/utils" +) + +func (m *NetworkManager) HandleGetNodeID(w http.ResponseWriter, r *http.Request) { + if m.ControllerID == "" { + //Node id not exists. Check again + instanceInfo, err := getControllerInfo(m.option.AuthToken, m.option.ApiPort) + if err != nil { + utils.SendErrorResponse(w, "unable to access node id information") + return + } + + m.ControllerID = instanceInfo.Address + } + + js, _ := json.Marshal(m.ControllerID) + utils.SendJSONResponse(w, string(js)) +} + +func (m *NetworkManager) HandleAddNetwork(w http.ResponseWriter, r *http.Request) { + networkInfo, err := m.createNetwork() + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + //Network created. Assign it the standard network settings + err = m.configureNetwork(networkInfo.Nwid, "192.168.192.1", "192.168.192.254", "192.168.192.0/24") + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + // Return the new network ID + js, _ := json.Marshal(networkInfo.Nwid) + utils.SendJSONResponse(w, string(js)) +} + +func (m *NetworkManager) HandleRemoveNetwork(w http.ResponseWriter, r *http.Request) { + networkID, err := utils.PostPara(r, "id") + if err != nil { + utils.SendErrorResponse(w, "invalid or empty network id given") + return + } + + if !m.networkExists(networkID) { + utils.SendErrorResponse(w, "network id not exists") + return + } + + err = m.deleteNetwork(networkID) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + } + + utils.SendOK(w) +} + +func (m *NetworkManager) HandleListNetwork(w http.ResponseWriter, r *http.Request) { + netid, _ := utils.GetPara(r, "netid") + if netid != "" { + targetNetInfo, err := m.getNetworkInfoById(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal(targetNetInfo) + utils.SendJSONResponse(w, string(js)) + + } else { + // Return the list of networks as JSON + networkIds, err := m.listNetworkIds() + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + networkInfos := []*NetworkInfo{} + for _, id := range networkIds { + thisNetInfo, err := m.getNetworkInfoById(id) + if err == nil { + networkInfos = append(networkInfos, thisNetInfo) + } + } + + js, _ := json.Marshal(networkInfos) + utils.SendJSONResponse(w, string(js)) + } + +} + +func (m *NetworkManager) HandleNetworkNaming(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "network id not given") + return + } + + if !m.networkExists(netid) { + utils.SendErrorResponse(w, "network not eixsts") + } + + newName, _ := utils.PostPara(r, "name") + newDesc, _ := utils.PostPara(r, "desc") + if newName != "" && newDesc != "" { + //Strip away html from name and desc + re := regexp.MustCompile("<[^>]*>") + newName := re.ReplaceAllString(newName, "") + newDesc := re.ReplaceAllString(newDesc, "") + + //Set the new network name and desc + err = m.setNetworkNameAndDescription(netid, newName, newDesc) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) + } else { + //Get current name and description + name, desc, err := m.getNetworkNameAndDescription(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal([]string{name, desc}) + utils.SendJSONResponse(w, string(js)) + } +} + +func (m *NetworkManager) HandleNetworkDetails(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "netid not given") + return + } + + targetNetwork, err := m.getNetworkInfoById(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal(targetNetwork) + utils.SendJSONResponse(w, string(js)) +} + +func (m *NetworkManager) HandleSetRanges(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "netid not given") + return + } + cidr, err := utils.PostPara(r, "cidr") + if err != nil { + utils.SendErrorResponse(w, "cidr not given") + return + } + ipstart, err := utils.PostPara(r, "ipstart") + if err != nil { + utils.SendErrorResponse(w, "ipstart not given") + return + } + ipend, err := utils.PostPara(r, "ipend") + if err != nil { + utils.SendErrorResponse(w, "ipend not given") + return + } + + //Validate the CIDR is real, the ip range is within the CIDR range + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + utils.SendErrorResponse(w, "invalid cidr string given") + return + } + + startIP := net.ParseIP(ipstart) + endIP := net.ParseIP(ipend) + if startIP == nil || endIP == nil { + utils.SendErrorResponse(w, "invalid start or end ip given") + return + } + + withinRange := ipnet.Contains(startIP) && ipnet.Contains(endIP) + if !withinRange { + utils.SendErrorResponse(w, "given CIDR did not cover all of the start to end ip range") + return + } + + err = m.configureNetwork(netid, startIP.String(), endIP.String(), strings.TrimSpace(cidr)) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} + +// Handle listing of network members. Set details=true for listing all details +func (m *NetworkManager) HandleMemberList(w http.ResponseWriter, r *http.Request) { + netid, err := utils.GetPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "netid is empty") + return + } + + details, _ := utils.GetPara(r, "detail") + + memberIds, err := m.getNetworkMembers(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + if details == "" { + //Only show client ids + js, _ := json.Marshal(memberIds) + utils.SendJSONResponse(w, string(js)) + } else { + //Show detail members info + detailMemberInfo := []*MemberInfo{} + for _, thisMemberId := range memberIds { + memInfo, err := m.getNetworkMemberInfo(netid, thisMemberId) + if err == nil { + detailMemberInfo = append(detailMemberInfo, memInfo) + } + } + + js, _ := json.Marshal(detailMemberInfo) + utils.SendJSONResponse(w, string(js)) + } +} + +// Handle Authorization of members +func (m *NetworkManager) HandleMemberAuthorization(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + //Check if the target memeber exists + if !m.memberExistsInNetwork(netid, memberid) { + utils.SendErrorResponse(w, "member not exists in given network") + return + } + + setAuthorized, err := utils.PostPara(r, "auth") + if err != nil || setAuthorized == "" { + //Get the member authorization state + memberInfo, err := m.getNetworkMemberInfo(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal(memberInfo.Authorized) + utils.SendJSONResponse(w, string(js)) + } else if setAuthorized == "true" { + m.AuthorizeMember(netid, memberid, true) + } else if setAuthorized == "false" { + m.AuthorizeMember(netid, memberid, false) + } else { + utils.SendErrorResponse(w, "unknown operation state: "+setAuthorized) + } +} + +// Handle Delete or Add IP for a member in a network +func (m *NetworkManager) HandleMemberIP(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + opr, err := utils.PostPara(r, "opr") + if err != nil { + utils.SendErrorResponse(w, "opr not defined") + return + } + + targetip, _ := utils.PostPara(r, "ip") + + memberInfo, err := m.getNetworkMemberInfo(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + if opr == "add" { + if targetip == "" { + utils.SendErrorResponse(w, "ip not set") + return + } + + if !isValidIPAddr(targetip) { + utils.SendErrorResponse(w, "ip address not valid") + return + } + + newIpList := append(memberInfo.IPAssignments, targetip) + err = m.setAssignedIps(netid, memberid, newIpList) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + utils.SendOK(w) + + } else if opr == "del" { + if targetip == "" { + utils.SendErrorResponse(w, "ip not set") + return + } + + //Delete user ip from the list + newIpList := []string{} + for _, thisIp := range memberInfo.IPAssignments { + if thisIp != targetip { + newIpList = append(newIpList, thisIp) + } + } + + err = m.setAssignedIps(netid, memberid, newIpList) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + utils.SendOK(w) + } else if opr == "get" { + js, _ := json.Marshal(memberInfo.IPAssignments) + utils.SendJSONResponse(w, string(js)) + } else { + utils.SendErrorResponse(w, "unsupported opr type: "+opr) + } +} + +// Handle naming for members +func (m *NetworkManager) HandleMemberNaming(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + if !m.memberExistsInNetwork(netid, memberid) { + utils.SendErrorResponse(w, "target member not exists in given network") + return + } + + //Read memeber data + targetMemberData := m.GetMemberMetaData(netid, memberid) + + newname, err := utils.PostPara(r, "name") + if err != nil { + //Send over the member data + js, _ := json.Marshal(targetMemberData) + utils.SendJSONResponse(w, string(js)) + } else { + //Write member data + targetMemberData.Name = newname + m.WriteMemeberMetaData(netid, memberid, targetMemberData) + utils.SendOK(w) + } +} + +// Handle delete of a given memver +func (m *NetworkManager) HandleMemberDelete(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + //Check if that member is authorized. + memberInfo, err := m.getNetworkMemberInfo(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, "member not exists in given GANet") + return + } + + if memberInfo.Authorized { + //Deauthorized this member before deleting + m.AuthorizeMember(netid, memberid, false) + } + + //Remove the memeber + err = m.deleteMember(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} + +// Check if a given network id is a network hosted on this zoraxy node +func (m *NetworkManager) IsLocalGAN(networkId string) bool { + networks, err := m.listNetworkIds() + if err != nil { + return false + } + + for _, network := range networks { + if network == networkId { + return true + } + } + + return false +} + +// Handle server instant joining a given network +func (m *NetworkManager) HandleServerJoinNetwork(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + //Check if the target network is a network hosted on this server + if !m.IsLocalGAN(netid) { + utils.SendErrorResponse(w, "given network is not a GAN hosted on this node") + return + } + + if m.memberExistsInNetwork(netid, m.ControllerID) { + utils.SendErrorResponse(w, "controller already inside network") + return + } + + //Join the network + err = m.joinNetwork(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} + +// Handle server instant leaving a given network +func (m *NetworkManager) HandleServerLeaveNetwork(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + //Check if the target network is a network hosted on this server + if !m.IsLocalGAN(netid) { + utils.SendErrorResponse(w, "given network is not a GAN hosted on this node") + return + } + + //Leave the network + err = m.leaveNetwork(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + //Remove it from target network if it is authorized + err = m.deleteMember(netid, m.ControllerID) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} diff --git a/example/plugins/ztnc/mod/ganserv/network.go b/example/plugins/ztnc/mod/ganserv/network.go new file mode 100644 index 0000000..9f4ec73 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/network.go @@ -0,0 +1,39 @@ +package ganserv + +import ( + "fmt" + "math/rand" + "net" + "time" +) + +//Get a random free IP from the pool +func (n *Network) GetRandomFreeIP() (net.IP, error) { + // Get all IP addresses in the subnet + ips, err := GetAllAddressFromCIDR(n.CIDR) + if err != nil { + return nil, err + } + + // Filter out used IPs + usedIPs := make(map[string]bool) + for _, node := range n.Nodes { + usedIPs[node.ManagedIP.String()] = true + } + availableIPs := []string{} + for _, ip := range ips { + if !usedIPs[ip] { + availableIPs = append(availableIPs, ip) + } + } + + // Randomly choose an available IP + if len(availableIPs) == 0 { + return nil, fmt.Errorf("no available IP") + } + rand.Seed(time.Now().UnixNano()) + randIndex := rand.Intn(len(availableIPs)) + pickedFreeIP := availableIPs[randIndex] + + return net.ParseIP(pickedFreeIP), nil +} diff --git a/example/plugins/ztnc/mod/ganserv/network_test.go b/example/plugins/ztnc/mod/ganserv/network_test.go new file mode 100644 index 0000000..2002b9f --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/network_test.go @@ -0,0 +1,55 @@ +package ganserv_test + +import ( + "fmt" + "net" + "strconv" + "testing" + + "aroz.org/zoraxy/ztnc/mod/ganserv" +) + +func TestGetRandomFreeIP(t *testing.T) { + n := ganserv.Network{ + CIDR: "172.16.0.0/12", + Nodes: []*ganserv.Node{ + { + Name: "nodeC1", + ManagedIP: net.ParseIP("172.16.1.142"), + }, + { + Name: "nodeC2", + ManagedIP: net.ParseIP("172.16.5.174"), + }, + }, + } + + // Call the function for 10 times + for i := 0; i < 10; i++ { + freeIP, err := n.GetRandomFreeIP() + fmt.Println("["+strconv.Itoa(i)+"] Free IP address assigned: ", freeIP) + + // Assert that no error occurred + if err != nil { + t.Errorf("Unexpected error: %s", err.Error()) + } + + // Assert that the returned IP is a valid IPv4 address + if freeIP.To4() == nil { + t.Errorf("Invalid IP address format: %s", freeIP.String()) + } + + // Assert that the returned IP is not already used by a node + for _, node := range n.Nodes { + if freeIP.Equal(node.ManagedIP) { + t.Errorf("Returned IP is already in use: %s", freeIP.String()) + } + } + + n.Nodes = append(n.Nodes, &ganserv.Node{ + Name: "NodeT" + strconv.Itoa(i), + ManagedIP: freeIP, + }) + } + +} diff --git a/example/plugins/ztnc/mod/ganserv/utils.go b/example/plugins/ztnc/mod/ganserv/utils.go new file mode 100644 index 0000000..684f597 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/utils.go @@ -0,0 +1,55 @@ +package ganserv + +import ( + "net" +) + +//Generate all ip address from a CIDR +func GetAllAddressFromCIDR(cidr string) ([]string, error) { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + + var ips []string + for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) { + ips = append(ips, ip.String()) + } + // remove network address and broadcast address + return ips[1 : len(ips)-1], nil +} + +func inc(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} + +func isValidIPAddr(ipAddr string) bool { + ip := net.ParseIP(ipAddr) + if ip == nil { + return false + } + + return true +} + +func ipWithinCIDR(ipAddr string, cidr string) bool { + // Parse the CIDR string + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + + // Parse the IP address + ip := net.ParseIP(ipAddr) + if ip == nil { + return false + } + + // Check if the IP address is in the CIDR range + return ipNet.Contains(ip) +} diff --git a/example/plugins/ztnc/mod/ganserv/zerotier.go b/example/plugins/ztnc/mod/ganserv/zerotier.go new file mode 100644 index 0000000..fa1fd0b --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/zerotier.go @@ -0,0 +1,669 @@ +package ganserv + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" +) + +/* + zerotier.go + + This hold the functions that required to communicate with + a zerotier instance + + See more on + https://docs.zerotier.com/self-hosting/network-controllers/ + +*/ + +type NodeInfo struct { + Address string `json:"address"` + Clock int64 `json:"clock"` + Config struct { + Settings struct { + AllowTCPFallbackRelay bool `json:"allowTcpFallbackRelay,omitempty"` + ForceTCPRelay bool `json:"forceTcpRelay,omitempty"` + HomeDir string `json:"homeDir,omitempty"` + ListeningOn []string `json:"listeningOn,omitempty"` + PortMappingEnabled bool `json:"portMappingEnabled,omitempty"` + PrimaryPort int `json:"primaryPort,omitempty"` + SecondaryPort int `json:"secondaryPort,omitempty"` + SoftwareUpdate string `json:"softwareUpdate,omitempty"` + SoftwareUpdateChannel string `json:"softwareUpdateChannel,omitempty"` + SurfaceAddresses []string `json:"surfaceAddresses,omitempty"` + TertiaryPort int `json:"tertiaryPort,omitempty"` + } `json:"settings"` + } `json:"config"` + Online bool `json:"online"` + PlanetWorldID int `json:"planetWorldId"` + PlanetWorldTimestamp int64 `json:"planetWorldTimestamp"` + PublicIdentity string `json:"publicIdentity"` + TCPFallbackActive bool `json:"tcpFallbackActive"` + Version string `json:"version"` + VersionBuild int `json:"versionBuild"` + VersionMajor int `json:"versionMajor"` + VersionMinor int `json:"versionMinor"` + VersionRev int `json:"versionRev"` +} +type ErrResp struct { + Message string `json:"message"` +} + +type NetworkInfo struct { + AuthTokens []interface{} `json:"authTokens"` + AuthorizationEndpoint string `json:"authorizationEndpoint"` + Capabilities []interface{} `json:"capabilities"` + ClientID string `json:"clientId"` + CreationTime int64 `json:"creationTime"` + DNS []interface{} `json:"dns"` + EnableBroadcast bool `json:"enableBroadcast"` + ID string `json:"id"` + IPAssignmentPools []interface{} `json:"ipAssignmentPools"` + Mtu int `json:"mtu"` + MulticastLimit int `json:"multicastLimit"` + Name string `json:"name"` + Nwid string `json:"nwid"` + Objtype string `json:"objtype"` + Private bool `json:"private"` + RemoteTraceLevel int `json:"remoteTraceLevel"` + RemoteTraceTarget interface{} `json:"remoteTraceTarget"` + Revision int `json:"revision"` + Routes []interface{} `json:"routes"` + Rules []struct { + Not bool `json:"not"` + Or bool `json:"or"` + Type string `json:"type"` + } `json:"rules"` + RulesSource string `json:"rulesSource"` + SsoEnabled bool `json:"ssoEnabled"` + Tags []interface{} `json:"tags"` + V4AssignMode struct { + Zt bool `json:"zt"` + } `json:"v4AssignMode"` + V6AssignMode struct { + SixPlane bool `json:"6plane"` + Rfc4193 bool `json:"rfc4193"` + Zt bool `json:"zt"` + } `json:"v6AssignMode"` +} + +type MemberInfo struct { + ActiveBridge bool `json:"activeBridge"` + Address string `json:"address"` + AuthenticationExpiryTime int `json:"authenticationExpiryTime"` + Authorized bool `json:"authorized"` + Capabilities []interface{} `json:"capabilities"` + CreationTime int64 `json:"creationTime"` + ID string `json:"id"` + Identity string `json:"identity"` + IPAssignments []string `json:"ipAssignments"` + LastAuthorizedCredential interface{} `json:"lastAuthorizedCredential"` + LastAuthorizedCredentialType string `json:"lastAuthorizedCredentialType"` + LastAuthorizedTime int `json:"lastAuthorizedTime"` + LastDeauthorizedTime int `json:"lastDeauthorizedTime"` + NoAutoAssignIps bool `json:"noAutoAssignIps"` + Nwid string `json:"nwid"` + Objtype string `json:"objtype"` + RemoteTraceLevel int `json:"remoteTraceLevel"` + RemoteTraceTarget interface{} `json:"remoteTraceTarget"` + Revision int `json:"revision"` + SsoExempt bool `json:"ssoExempt"` + Tags []interface{} `json:"tags"` + VMajor int `json:"vMajor"` + VMinor int `json:"vMinor"` + VProto int `json:"vProto"` + VRev int `json:"vRev"` +} + +// Get the zerotier node info from local service +func getControllerInfo(token string, apiPort int) (*NodeInfo, error) { + url := "http://localhost:" + strconv.Itoa(apiPort) + "/status" + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("X-ZT1-AUTH", token) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + //Read from zerotier service instance + + defer resp.Body.Close() + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + //Parse the payload into struct + thisInstanceInfo := NodeInfo{} + err = json.Unmarshal(payload, &thisInstanceInfo) + if err != nil { + return nil, err + } + + return &thisInstanceInfo, nil +} + +/* + Network Functions +*/ +//Create a zerotier network +func (m *NetworkManager) createNetwork() (*NetworkInfo, error) { + url := fmt.Sprintf("http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/%s______", m.ControllerID) + + data := []byte(`{}`) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + req.Header.Set("X-ZT1-AUTH", m.authToken) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + networkInfo := NetworkInfo{} + err = json.Unmarshal(payload, &networkInfo) + if err != nil { + return nil, err + } + + return &networkInfo, nil +} + +// List network details +func (m *NetworkManager) getNetworkInfoById(networkId string) (*NetworkInfo, error) { + req, err := http.NewRequest("GET", os.ExpandEnv("http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/"+networkId+"/"), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + thisNetworkInfo := NetworkInfo{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(payload, &thisNetworkInfo) + if err != nil { + return nil, err + } + + return &thisNetworkInfo, nil +} + +func (m *NetworkManager) setNetworkInfoByID(networkId string, newNetworkInfo *NetworkInfo) error { + payloadBytes, err := json.Marshal(newNetworkInfo) + if err != nil { + return err + } + payloadBuffer := bytes.NewBuffer(payloadBytes) + + // Create the HTTP request + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkId + "/" + req, err := http.NewRequest("POST", url, payloadBuffer) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + req.Header.Set("Content-Type", "application/json") + + // Send the HTTP request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// List network IDs +func (m *NetworkManager) listNetworkIds() ([]string, error) { + req, err := http.NewRequest("GET", "http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/", nil) + if err != nil { + return []string{}, err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return []string{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return []string{}, errors.New("network error") + } + + networkIds := []string{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return []string{}, err + } + + err = json.Unmarshal(payload, &networkIds) + if err != nil { + return []string{}, err + } + + return networkIds, nil +} + +// wrapper for checking if a network id exists +func (m *NetworkManager) networkExists(networkId string) bool { + networkIds, err := m.listNetworkIds() + if err != nil { + return false + } + + for _, thisid := range networkIds { + if thisid == networkId { + return true + } + } + + return false +} + +// delete a network +func (m *NetworkManager) deleteNetwork(networkID string) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkID + "/" + client := &http.Client{} + + // Create a new DELETE request + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + // Add the required authorization header + req.Header.Set("X-Zt1-Auth", m.authToken) + + // Send the request and get the response + resp, err := client.Do(req) + if err != nil { + return err + } + + // Close the response body when we're done + defer resp.Body.Close() + s, err := io.ReadAll(resp.Body) + fmt.Println(string(s), err, resp.StatusCode) + + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Configure network +// Example: configureNetwork(netid, "192.168.192.1", "192.168.192.254", "192.168.192.0/24") +func (m *NetworkManager) configureNetwork(networkID string, ipRangeStart string, ipRangeEnd string, routeTarget string) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkID + "/" + data := map[string]interface{}{ + "ipAssignmentPools": []map[string]string{ + { + "ipRangeStart": ipRangeStart, + "ipRangeEnd": ipRangeEnd, + }, + }, + "routes": []map[string]interface{}{ + { + "target": routeTarget, + "via": nil, + }, + }, + "v4AssignMode": "zt", + "private": true, + } + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +func (m *NetworkManager) setAssignedIps(networkID string, memid string, newIps []string) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkID + "/member/" + memid + data := map[string]interface{}{ + "ipAssignments": newIps, + } + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +func (m *NetworkManager) setNetworkNameAndDescription(netid string, name string, desc string) error { + // Convert string to rune slice + r := []rune(name) + + // Loop over runes and remove non-ASCII characters + for i, v := range r { + if v > 127 { + r[i] = ' ' + } + } + + // Convert back to string and trim whitespace + name = strings.TrimSpace(string(r)) + + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + netid + "/" + data := map[string]interface{}{ + "name": name, + } + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + meta := m.GetNetworkMetaData(netid) + if meta != nil { + meta.Desc = desc + m.WriteNetworkMetaData(netid, meta) + } + + return nil +} + +func (m *NetworkManager) getNetworkNameAndDescription(netid string) (string, string, error) { + //Get name from network info + netinfo, err := m.getNetworkInfoById(netid) + if err != nil { + return "", "", err + } + + name := netinfo.Name + + //Get description from meta + desc := "" + networkMeta := m.GetNetworkMetaData(netid) + if networkMeta != nil { + desc = networkMeta.Desc + } + + return name, desc, nil +} + +/* + Member functions +*/ + +func (m *NetworkManager) getNetworkMembers(networkId string) ([]string, error) { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkId + "/member" + reqBody := bytes.NewBuffer([]byte{}) + req, err := http.NewRequest("GET", url, reqBody) + if err != nil { + return nil, err + } + + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("failed to get network members") + } + + memberList := map[string]int{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(payload, &memberList) + if err != nil { + return nil, err + } + + members := make([]string, 0, len(memberList)) + for k := range memberList { + members = append(members, k) + } + + return members, nil +} + +func (m *NetworkManager) memberExistsInNetwork(netid string, memid string) bool { + //Get a list of member + memberids, err := m.getNetworkMembers(netid) + if err != nil { + return false + } + for _, thisMemberId := range memberids { + if thisMemberId == memid { + return true + } + } + + return false +} + +// Get a network memeber info by netid and memberid +func (m *NetworkManager) getNetworkMemberInfo(netid string, memberid string) (*MemberInfo, error) { + req, err := http.NewRequest("GET", "http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/"+netid+"/member/"+memberid, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + thisMemeberInfo := &MemberInfo{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(payload, &thisMemeberInfo) + if err != nil { + return nil, err + } + + return thisMemeberInfo, nil +} + +// Set the authorization state of a member +func (m *NetworkManager) AuthorizeMember(netid string, memberid string, setAuthorized bool) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + netid + "/member/" + memberid + payload := []byte(`{"authorized": true}`) + if !setAuthorized { + payload = []byte(`{"authorized": false}`) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + req.Header.Set("X-ZT1-AUTH", m.authToken) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Delete a member from the network +func (m *NetworkManager) deleteMember(netid string, memid string) error { + req, err := http.NewRequest("DELETE", "http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/"+netid+"/member/"+memid, nil) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", os.ExpandEnv(m.authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Make the host to join a given network +func (m *NetworkManager) joinNetwork(netid string) error { + req, err := http.NewRequest("POST", "http://localhost:"+strconv.Itoa(m.apiPort)+"/network/"+netid, nil) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", os.ExpandEnv(m.authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Make the host to leave a given network +func (m *NetworkManager) leaveNetwork(netid string) error { + req, err := http.NewRequest("DELETE", "http://localhost:"+strconv.Itoa(m.apiPort)+"/network/"+netid, nil) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", os.ExpandEnv(m.authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} diff --git a/example/plugins/ztnc/mod/utils/conv.go b/example/plugins/ztnc/mod/utils/conv.go new file mode 100644 index 0000000..6adf753 --- /dev/null +++ b/example/plugins/ztnc/mod/utils/conv.go @@ -0,0 +1,105 @@ +package utils + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +func StringToInt64(number string) (int64, error) { + i, err := strconv.ParseInt(number, 10, 64) + if err != nil { + return -1, err + } + return i, nil +} + +func Int64ToString(number int64) string { + convedNumber := strconv.FormatInt(number, 10) + return convedNumber +} + +func ReplaceSpecialCharacters(filename string) string { + replacements := map[string]string{ + "#": "%pound%", + "&": "%amp%", + "{": "%left_cur%", + "}": "%right_cur%", + "\\": "%backslash%", + "<": "%left_ang%", + ">": "%right_ang%", + "*": "%aster%", + "?": "%quest%", + " ": "%space%", + "$": "%dollar%", + "!": "%exclan%", + "'": "%sin_q%", + "\"": "%dou_q%", + ":": "%colon%", + "@": "%at%", + "+": "%plus%", + "`": "%backtick%", + "|": "%pipe%", + "=": "%equal%", + ".": "_", + "/": "-", + } + + for char, replacement := range replacements { + filename = strings.ReplaceAll(filename, char, replacement) + } + + return filename +} + +/* Zip File Handler */ +// zipFiles compresses multiple files into a single zip archive file +func ZipFiles(filename string, files ...string) error { + newZipFile, err := os.Create(filename) + if err != nil { + return err + } + defer newZipFile.Close() + + zipWriter := zip.NewWriter(newZipFile) + defer zipWriter.Close() + + for _, file := range files { + if err := addFileToZip(zipWriter, file); err != nil { + return err + } + } + return nil +} + +// addFileToZip adds an individual file to a zip archive +func addFileToZip(zipWriter *zip.Writer, filename string) error { + fileToZip, err := os.Open(filename) + if err != nil { + return err + } + defer fileToZip.Close() + + info, err := fileToZip.Stat() + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + header.Name = filepath.Base(filename) + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + _, err = io.Copy(writer, fileToZip) + return err +} diff --git a/example/plugins/ztnc/mod/utils/template.go b/example/plugins/ztnc/mod/utils/template.go new file mode 100644 index 0000000..e5772a8 --- /dev/null +++ b/example/plugins/ztnc/mod/utils/template.go @@ -0,0 +1,19 @@ +package utils + +import ( + "net/http" +) + +/* + Web Template Generator + + This is the main system core module that perform function similar to what PHP did. + To replace part of the content of any file, use {{paramter}} to replace it. + + +*/ + +func SendHTMLResponse(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(msg)) +} diff --git a/example/plugins/ztnc/mod/utils/utils.go b/example/plugins/ztnc/mod/utils/utils.go new file mode 100644 index 0000000..2fe1ffd --- /dev/null +++ b/example/plugins/ztnc/mod/utils/utils.go @@ -0,0 +1,202 @@ +package utils + +import ( + "errors" + "log" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +/* + Common + + Some commonly used functions in ArozOS + +*/ + +// Response related +func SendTextResponse(w http.ResponseWriter, msg string) { + w.Write([]byte(msg)) +} + +// Send JSON response, with an extra json header +func SendJSONResponse(w http.ResponseWriter, json string) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(json)) +} + +func SendErrorResponse(w http.ResponseWriter, errMsg string) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"error\":\"" + errMsg + "\"}")) +} + +func SendOK(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("\"OK\"")) +} + +// Get GET parameter +func GetPara(r *http.Request, key string) (string, error) { + // Get first value from the URL query + value := r.URL.Query().Get(key) + if len(value) == 0 { + return "", errors.New("invalid " + key + " given") + } + return value, nil +} + +// Get GET paramter as boolean, accept 1 or true +func GetBool(r *http.Request, key string) (bool, error) { + x, err := GetPara(r, key) + if err != nil { + return false, err + } + + // Convert to lowercase and trim spaces just once to compare + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "on": + return true, nil + case "0", "false", "off": + return false, nil + } + + return false, errors.New("invalid boolean given") +} + +// Get POST parameter +func PostPara(r *http.Request, key string) (string, error) { + // Try to parse the form + if err := r.ParseForm(); err != nil { + return "", err + } + // Get first value from the form + x := r.Form.Get(key) + if len(x) == 0 { + return "", errors.New("invalid " + key + " given") + } + return x, nil +} + +// Get POST paramter as boolean, accept 1 or true +func PostBool(r *http.Request, key string) (bool, error) { + x, err := PostPara(r, key) + if err != nil { + return false, err + } + + // Convert to lowercase and trim spaces just once to compare + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "on": + return true, nil + case "0", "false", "off": + return false, nil + } + + return false, errors.New("invalid boolean given") +} + +// Get POST paramter as int +func PostInt(r *http.Request, key string) (int, error) { + x, err := PostPara(r, key) + if err != nil { + return 0, err + } + + x = strings.TrimSpace(x) + rx, err := strconv.Atoi(x) + if err != nil { + return 0, err + } + + return rx, nil +} + +func FileExists(filename string) bool { + _, err := os.Stat(filename) + if err == nil { + // File exists + return true + } else if errors.Is(err, os.ErrNotExist) { + // File does not exist + return false + } + // Some other error + return false +} + +func IsDir(path string) bool { + if !FileExists(path) { + return false + } + fi, err := os.Stat(path) + if err != nil { + log.Fatal(err) + return false + } + switch mode := fi.Mode(); { + case mode.IsDir(): + return true + case mode.IsRegular(): + return false + } + return false +} + +func TimeToString(targetTime time.Time) string { + return targetTime.Format("2006-01-02 15:04:05") +} + +// Check if given string in a given slice +func StringInArray(arr []string, str string) bool { + for _, a := range arr { + if a == str { + return true + } + } + return false +} + +func StringInArrayIgnoreCase(arr []string, str string) bool { + smallArray := []string{} + for _, item := range arr { + smallArray = append(smallArray, strings.ToLower(item)) + } + + return StringInArray(smallArray, strings.ToLower(str)) +} + +// Validate if the listening address is correct +func ValidateListeningAddress(address string) bool { + // Check if the address starts with a colon, indicating it's just a port + if strings.HasPrefix(address, ":") { + return true + } + + // Split the address into host and port parts + host, port, err := net.SplitHostPort(address) + if err != nil { + // Try to parse it as just a port + if _, err := strconv.Atoi(address); err == nil { + return false // It's just a port number + } + return false // It's an invalid address + } + + // Check if the port part is a valid number + if _, err := strconv.Atoi(port); err != nil { + return false + } + + // Check if the host part is a valid IP address or empty (indicating any IP) + if host != "" { + if net.ParseIP(host) == nil { + return false + } + } + + return true +} \ No newline at end of file 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 new file mode 100644 index 0000000..c529e99 --- /dev/null +++ b/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,128 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + 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 +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + 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 { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + 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 new file mode 100644 index 0000000..b316e6d --- /dev/null +++ b/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,174 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type CaptureRule struct { + CapturePath string `json:"capture_path"` + IncludeSubPaths bool `json:"include_sub_paths"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Global Capture Settings + + 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 + + */ + 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) + + /* + Always Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + these always applies + */ + 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) + + /* 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 + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + 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 + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + 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() +} diff --git a/example/plugins/ztnc/start.go b/example/plugins/ztnc/start.go new file mode 100644 index 0000000..1090031 --- /dev/null +++ b/example/plugins/ztnc/start.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "net/http" + "os" + + "aroz.org/zoraxy/ztnc/mod/database" + "aroz.org/zoraxy/ztnc/mod/database/dbinc" + "aroz.org/zoraxy/ztnc/mod/ganserv" + "aroz.org/zoraxy/ztnc/mod/utils" +) + +func startGanNetworkController() error { + fmt.Println("Starting ZeroTier Network Controller") + //Create a new database + var err error + sysdb, err = database.NewDatabase(DB_FILE_PATH, dbinc.BackendBoltDB) + if err != nil { + return err + } + + //Initiate the GAN server manager + usingZtAuthToken := "" + ztAPIPort := 9993 + + if utils.FileExists(AUTH_TOKEN_PATH) { + authToken, err := os.ReadFile(AUTH_TOKEN_PATH) + if err != nil { + fmt.Println("Error reading auth config file:", err) + return err + } + usingZtAuthToken = string(authToken) + fmt.Println("Loaded ZeroTier Auth Token from file") + } + + if usingZtAuthToken == "" { + usingZtAuthToken, err = ganserv.TryLoadorAskUserForAuthkey() + if err != nil { + fmt.Println("Error getting ZeroTier Auth Token:", err) + } + } + + ganManager = ganserv.NewNetworkManager(&ganserv.NetworkManagerOptions{ + AuthToken: usingZtAuthToken, + ApiPort: ztAPIPort, + Database: sysdb, + }) + + return nil +} + +func initApiEndpoints() { + //UI_RELPATH must be the same as the one in the plugin intro spect + // as Zoraxy plugin UI proxy will only forward the UI path to your plugin + http.HandleFunc(UI_RELPATH+"/api/gan/network/info", ganManager.HandleGetNodeID) + http.HandleFunc(UI_RELPATH+"/api/gan/network/add", ganManager.HandleAddNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/remove", ganManager.HandleRemoveNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/list", ganManager.HandleListNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/name", ganManager.HandleNetworkNaming) + http.HandleFunc(UI_RELPATH+"/api/gan/network/setRange", ganManager.HandleSetRanges) + http.HandleFunc(UI_RELPATH+"/api/gan/network/join", ganManager.HandleServerJoinNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/leave", ganManager.HandleServerLeaveNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/members/list", ganManager.HandleMemberList) + http.HandleFunc(UI_RELPATH+"/api/gan/members/ip", ganManager.HandleMemberIP) + http.HandleFunc(UI_RELPATH+"/api/gan/members/name", ganManager.HandleMemberNaming) + http.HandleFunc(UI_RELPATH+"/api/gan/members/authorize", ganManager.HandleMemberAuthorization) + http.HandleFunc(UI_RELPATH+"/api/gan/members/delete", ganManager.HandleMemberDelete) +} diff --git a/example/plugins/ztnc/web/details.html b/example/plugins/ztnc/web/details.html new file mode 100644 index 0000000..37db9a0 --- /dev/null +++ b/example/plugins/ztnc/web/details.html @@ -0,0 +1,747 @@ + +
+ +
+ +

+ +
+

+
+

+ +
+ +
+

Settings

+
+ + + + + + + + + +
IPv4 Auto-Assign
+
+
+
+

Custom IP Range

+

Manual IP Range Configuration. The IP range must be within the selected CIDR range. +
Use Utilities > IP to CIDR tool if you are not too familiar with CIDR notations.

+
+
+ + +
+
+ + +
+
+
+ + +
+

Members

+

To join this network using command line, type sudo zerotier-cli join on your device terminal

+
+ + +
+
+ + + + + + + + + + + + + + + + + +
AuthAddressNameManaged IPAuthorized SinceVersionRemove
+
+
+

Add Controller as Member

+

Optionally you can add the network controller (ZeroTier running on the Zoraxy node) as member for cross GAN reverse proxy to bypass NAT limitations.

+ + +

+
+ diff --git a/example/plugins/ztnc/web/index.html b/example/plugins/ztnc/web/index.html new file mode 100644 index 0000000..34d2974 --- /dev/null +++ b/example/plugins/ztnc/web/index.html @@ -0,0 +1,262 @@ + + + + + + + + + Global Area Network | Zoraxy + + + + + + + + + + + + + + +
+
+

Global Area Network

+

Virtual Network Hub that allows all networked devices to communicate as if they all reside in the same physical data center or cloud region

+
+
+
+
+

+ +
Network Controller ID
+

+
+
+
+ +
+
0
+
Networks
+
+
+
+ +
+
0
+
Connected Nodes
+
+
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + +
Network IDNameDescriptionSubnet (Assign Range)NodesActions
No Global Area Network Found on this host
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/accesslist.go b/src/accesslist.go index 2df35d7..6cdb34e 100644 --- a/src/accesslist.go +++ b/src/accesslist.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "net/http" + "sort" "strings" "github.com/google/uuid" @@ -545,3 +546,39 @@ func handleWhitelistEnable(w http.ResponseWriter, r *http.Request) { utils.SendOK(w) } } + +// List all quick ban ip address +func handleListQuickBan(w http.ResponseWriter, r *http.Request) { + currentSummary := statisticCollector.GetCurrentDailySummary() + type quickBanEntry struct { + IpAddr string + Count int + CountryCode string + } + result := []quickBanEntry{} + currentSummary.RequestClientIp.Range(func(key, value interface{}) bool { + ip := key.(string) + count := value.(int) + thisEntry := quickBanEntry{ + IpAddr: ip, + Count: count, + } + + //Get the country code + geoinfo, err := geodbStore.ResolveCountryCodeFromIP(ip) + if err == nil { + thisEntry.CountryCode = geoinfo.CountryIsoCode + } + + result = append(result, thisEntry) + return true + }) + + //Sort result based on count + sort.Slice(result, func(i, j int) bool { + return result[i].Count > result[j].Count + }) + + js, _ := json.Marshal(result) + utils.SendJSONResponse(w, string(js)) +} diff --git a/src/api.go b/src/api.go index 1507289..112d5b0 100644 --- a/src/api.go +++ b/src/api.go @@ -114,6 +114,9 @@ func RegisterAccessRuleAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/whitelist/ip/add", handleIpWhitelistAdd) authRouter.HandleFunc("/api/whitelist/ip/remove", handleIpWhitelistRemove) authRouter.HandleFunc("/api/whitelist/enable", handleWhitelistEnable) + + /* Quick Ban List */ + authRouter.HandleFunc("/api/quickban/list", handleListQuickBan) } // Register the APIs for path blocking rules management functions, WIP @@ -235,6 +238,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 +350,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/def.go b/src/def.go index 23d2ae4..9e355c9 100644 --- a/src/def.go +++ b/src/def.go @@ -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,7 +43,7 @@ import ( const ( /* Build Constants */ SYSTEM_NAME = "Zoraxy" - SYSTEM_VERSION = "3.1.8" + SYSTEM_VERSION = "3.1.9" DEVELOPMENT_BUILD = false /* Development: Set to false to use embedded web fs */ /* System Constants */ @@ -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 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/dynamicproxy/dynamicproxy.go b/src/mod/dynamicproxy/dynamicproxy.go index f5c906b..677881a 100644 --- a/src/mod/dynamicproxy/dynamicproxy.go +++ b/src/mod/dynamicproxy/dynamicproxy.go @@ -314,7 +314,7 @@ func (router *Router) Restart() error { return err } - time.Sleep(800 * time.Millisecond) + time.Sleep(100 * time.Millisecond) // Start the server err = router.StartProxyService() if err != nil { diff --git a/src/mod/dynamicproxy/loadbalance/onlineStatus.go b/src/mod/dynamicproxy/loadbalance/onlineStatus.go index 6d2d591..a30217a 100644 --- a/src/mod/dynamicproxy/loadbalance/onlineStatus.go +++ b/src/mod/dynamicproxy/loadbalance/onlineStatus.go @@ -3,7 +3,6 @@ package loadbalance import ( "strconv" "strings" - "time" ) // Return if the target host is online @@ -36,6 +35,7 @@ func (m *RouteManager) NotifyHostOnlineState(upstreamIP string, isOnline bool) { // Set this host unreachable for a given amount of time defined in timeout // this shall be used in passive fallback. The uptime monitor should call to NotifyHostOnlineState() instead +/* func (m *RouteManager) NotifyHostUnreachableWithTimeout(upstreamIp string, timeout int64) { //if the upstream IP contains http or https, strip it upstreamIp = strings.TrimPrefix(upstreamIp, "http://") @@ -58,6 +58,7 @@ func (m *RouteManager) NotifyHostUnreachableWithTimeout(upstreamIp string, timeo m.NotifyHostOnlineState(upstreamIp, true) }() } +*/ // FilterOfflineOrigins return only online origins from a list of origins func (m *RouteManager) FilterOfflineOrigins(origins []*Upstream) []*Upstream { diff --git a/src/mod/dynamicproxy/loadbalance/originPicker.go b/src/mod/dynamicproxy/loadbalance/originPicker.go index 4c4ac75..d5229a8 100644 --- a/src/mod/dynamicproxy/loadbalance/originPicker.go +++ b/src/mod/dynamicproxy/loadbalance/originPicker.go @@ -13,6 +13,10 @@ import ( by this request. */ +const ( + STICKY_SESSION_NAME = "zr_sticky_session" +) + // GetRequestUpstreamTarget return the upstream target where this // request should be routed func (m *RouteManager) GetRequestUpstreamTarget(w http.ResponseWriter, r *http.Request, origins []*Upstream, useStickySession bool) (*Upstream, error) { @@ -49,9 +53,8 @@ func (m *RouteManager) GetRequestUpstreamTarget(w http.ResponseWriter, r *http.R //fmt.Println("DEBUG: (Sticky Session) Picking origin " + origins[targetOriginId].OriginIpOrDomain) return origins[targetOriginId], nil } - //No sticky session, get a random origin - m.clearSessionHandler(w, r) //Clear the session + //No sticky session, get a random origin //Filter the offline origins origins = m.FilterOfflineOrigins(origins) if len(origins) == 0 { @@ -78,7 +81,7 @@ func (m *RouteManager) GetUsableUpstreamCounts(origins []*Upstream) int { /* Features related to session access */ //Set a new origin for this connection by session func (m *RouteManager) setSessionHandler(w http.ResponseWriter, r *http.Request, originIpOrDomain string, index int) error { - session, err := m.SessionStore.Get(r, "STICKYSESSION") + session, err := m.SessionStore.Get(r, STICKY_SESSION_NAME) if err != nil { return err } @@ -93,24 +96,10 @@ func (m *RouteManager) setSessionHandler(w http.ResponseWriter, r *http.Request, return nil } -func (m *RouteManager) clearSessionHandler(w http.ResponseWriter, r *http.Request) error { - session, err := m.SessionStore.Get(r, "STICKYSESSION") - if err != nil { - return err - } - session.Options.MaxAge = -1 - session.Options.Path = "/" - err = session.Save(r, w) - if err != nil { - return err - } - return nil -} - // Get the previous connected origin from session func (m *RouteManager) getSessionHandler(r *http.Request, upstreams []*Upstream) (int, error) { // Get existing session - session, err := m.SessionStore.Get(r, "STICKYSESSION") + session, err := m.SessionStore.Get(r, STICKY_SESSION_NAME) if err != nil { return -1, err } @@ -119,7 +108,7 @@ func (m *RouteManager) getSessionHandler(r *http.Request, upstreams []*Upstream) originDomainRaw := session.Values["zr_sid_origin"] originIDRaw := session.Values["zr_sid_index"] - if originDomainRaw == nil || originIDRaw == nil { + if originDomainRaw == nil || originIDRaw == nil || originIDRaw == -1 { return -1, errors.New("no session has been set") } originDomain := originDomainRaw.(string) @@ -201,21 +190,3 @@ func getRandomUpstreamByWeight(upstreams []*Upstream) (*Upstream, int, error) { return nil, -1, errors.New("failed to pick an upstream origin server") } - -// IntRange returns a random integer in the range from min to max. -/* -func intRange(min, max int) (int, error) { - var result int - switch { - case min > max: - // Fail with error - return result, errors.New("min is greater than max") - case max == min: - result = max - case max > min: - b := rand.Intn(max-min) + min - result = min + int(b) - } - return result, nil -} -*/ diff --git a/src/mod/dynamicproxy/proxyRequestHandler.go b/src/mod/dynamicproxy/proxyRequestHandler.go index 142972d..f4d8fea 100644 --- a/src/mod/dynamicproxy/proxyRequestHandler.go +++ b/src/mod/dynamicproxy/proxyRequestHandler.go @@ -3,7 +3,6 @@ package dynamicproxy import ( "context" "errors" - "fmt" "log" "net" "net/http" @@ -211,9 +210,6 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe http.Error(w, "Request canceled", http.StatusRequestTimeout) h.Parent.logRequest(r, false, http.StatusRequestTimeout, "host-http", r.URL.Hostname()) } else { - //Notify the load balancer that the host is unreachable - fmt.Println(err.Error()) - h.Parent.loadBalancer.NotifyHostUnreachableWithTimeout(selectedUpstream.OriginIpOrDomain, PassiveLoadBalanceNotifyTimeout) http.ServeFile(w, r, "./web/rperror.html") h.Parent.logRequest(r, false, 521, "host-http", r.URL.Hostname()) } diff --git a/src/mod/netstat/netstat.go b/src/mod/netstat/netstat.go index 078c1f6..67724c1 100644 --- a/src/mod/netstat/netstat.go +++ b/src/mod/netstat/netstat.go @@ -212,6 +212,10 @@ func (n *NetStatBuffers) GetNetworkInterfaceStats() (int64, int64, error) { totalTx += counter.BytesSent } - // Convert bytes to bits + // Convert bytes to bits with overflow check + const maxInt64 = int64(^uint64(0) >> 1) + if totalRx*8 > uint64(maxInt64) || totalTx*8 > uint64(maxInt64) { + return 0, 0, errors.New("overflow detected when converting uint64 to int64") + } return int64(totalRx * 8), int64(totalTx * 8), nil } diff --git a/src/mod/plugins/forwarder.go b/src/mod/plugins/forwarder.go new file mode 100644 index 0000000..5089b27 --- /dev/null +++ b/src/mod/plugins/forwarder.go @@ -0,0 +1,26 @@ +package plugins + +import "net/http" + +/* + Forwarder.go + + This file handles the dynamic proxy routing forwarding + request to plugin capture path that handles the matching + request path registered when the plugin started +*/ + +func (m *Manager) GetHandlerPlugins(w http.ResponseWriter, r *http.Request) { + +} + +func (m *Manager) GetHandlerPluginsSubsets(w http.ResponseWriter, r *http.Request) { + +} + +func (p *Plugin) HandlePluginRoute(w http.ResponseWriter, r *http.Request) { + //Find the plugin that matches the request path + //If no plugin found, return 404 + //If found, forward the request to the plugin + +} diff --git a/src/mod/plugins/handler.go b/src/mod/plugins/handler.go new file mode 100644 index 0000000..fb1a651 --- /dev/null +++ b/src/mod/plugins/handler.go @@ -0,0 +1,89 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "net/http" + "path/filepath" + "sort" + "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 + } + + //Sort the plugin by its name + sort.Slice(plugins, func(i, j int) bool { + return plugins[i].Spec.Name < plugins[j].Spec.Name + }) + + 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/introspect.go b/src/mod/plugins/introspect.go new file mode 100644 index 0000000..988289e --- /dev/null +++ b/src/mod/plugins/introspect.go @@ -0,0 +1,68 @@ +package plugins + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "time" + + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" +) + +// LoadPlugin loads a plugin from the plugin directory +func (m *Manager) IsValidPluginFolder(path string) bool { + _, err := m.GetPluginEntryPoint(path) + return err == nil +} + +/* +LoadPluginSpec loads a plugin specification from the plugin directory +Zoraxy will start the plugin binary or the entry point script +with -introspect flag to get the plugin specification +*/ +func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) { + pluginEntryPoint, err := m.GetPluginEntryPoint(pluginPath) + if err != nil { + return nil, err + } + + pluginSpec, err := m.GetPluginSpec(pluginEntryPoint) + if err != nil { + return nil, err + } + + err = validatePluginSpec(pluginSpec) + if err != nil { + return nil, err + } + + return &Plugin{ + Spec: pluginSpec, + Enabled: false, + }, nil +} + +// GetPluginEntryPoint returns the plugin entry point +func (m *Manager) GetPluginSpec(entryPoint string) (*zoraxyPlugin.IntroSpect, error) { + pluginSpec := zoraxyPlugin.IntroSpect{} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, entryPoint, "-introspect") + output, err := cmd.Output() + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("plugin introspect timed out") + } + if err != nil { + return nil, err + } + + // 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 +} diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go new file mode 100644 index 0000000..88c5b23 --- /dev/null +++ b/src/mod/plugins/lifecycle.go @@ -0,0 +1,226 @@ +package plugins + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" +) + +func (m *Manager) StartPlugin(pluginID string) error { + plugin, ok := m.LoadedPlugins.Load(pluginID) + if !ok { + return errors.New("plugin not found") + } + + thisPlugin := plugin.(*Plugin) + + //Get the plugin Entry point + pluginEntryPoint, err := m.GetPluginEntryPoint(thisPlugin.RootDir) + if err != nil { + //Plugin removed after introspect? + return err + } + + //Get the absolute path of the plugin entry point to prevent messing up with the cwd + absolutePath, err := filepath.Abs(pluginEntryPoint) + if err != nil { + return err + } + + //Prepare plugin start configuration + pluginConfiguration := zoraxyPlugin.ConfigureSpec{ + Port: getRandomPortNumber(), + RuntimeConst: *m.Options.SystemConst, + } + 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 + } + } + }() + + //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).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 + } + + // Remove the trailing slash if it exists + pluginUIRelPath = strings.TrimSuffix(pluginUIRelPath, "/") + + pluginUIURL, err := url.Parse("http://127.0.0.1:" + strconv.Itoa(pluginListeningPort) + pluginUIRelPath) + if err != nil { + return err + } + + // Generate the plugin subpath to be trimmed + pluginMatchingPath := filepath.ToSlash(filepath.Join("/plugin.ui/"+targetPlugin.Spec.ID+"/")) + "/" + if targetPlugin.Spec.UIPath != "" { + targetPlugin.uiProxy = dpcore.NewDynamicProxyCore( + pluginUIURL, + pluginMatchingPath, + &dpcore.DpcoreOptions{ + IgnoreTLSVerification: true, + }, + ) + targetPlugin.AssignedPort = pluginListeningPort + 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 { + // 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) + 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() + } + } + + //Remove the UI proxy + thisPlugin.uiProxy = 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) + if !ok { + return false + } + if plugin.(*Plugin).process == nil { + return false + } + return plugin.(*Plugin).process.ProcessState == nil +} + +// BlockUntilAllProcessExited blocks until all the plugins processes have exited +func (m *Manager) BlockUntilAllProcessExited() { + m.LoadedPlugins.Range(func(key, value interface{}) bool { + plugin := value.(*Plugin) + if m.PluginStillRunning(value.(*Plugin).Spec.ID) { + //Wait for the plugin to exit + 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..dcf5bda 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..8e83901 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 new file mode 100644 index 0000000..5be4af4 --- /dev/null +++ b/src/mod/plugins/plugins.go @@ -0,0 +1,136 @@ +package plugins + +/* + Zoraxy Plugin Manager + + This module is responsible for managing plugins + loading plugins from the disk + enable / disable plugins + and forwarding traffic to plugins +*/ + +import ( + "errors" + "os" + "path/filepath" + "sync" + + "imuslab.com/zoraxy/mod/utils" +) + +// 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, + } +} + +// 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 { + return err + } + + for _, folder := range foldersInPluginDir { + if folder.IsDir() { + pluginPath := filepath.Join(m.Options.PluginDir, folder.Name()) + thisPlugin, err := m.LoadPluginSpec(pluginPath) + if err != nil { + m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err) + continue + } + thisPlugin.RootDir = filepath.ToSlash(pluginPath) + m.LoadedPlugins.Store(thisPlugin.Spec.ID, thisPlugin) + m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil) + + // If the plugin was enabled, start it now + 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) + } + } + } + } + + return nil +} + +// GetPluginByID returns a plugin by its ID +func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) { + plugin, ok := m.LoadedPlugins.Load(pluginID) + if !ok { + return nil, errors.New("plugin not found") + } + return plugin.(*Plugin), nil +} + +// EnablePlugin enables a plugin +func (m *Manager) EnablePlugin(pluginID string) error { + err := m.StartPlugin(pluginID) + if err != nil { + return err + } + m.Options.Database.Write("plugins", pluginID, true) + return nil +} + +// DisablePlugin disables a plugin +func (m *Manager) DisablePlugin(pluginID string) error { + err := m.StopPlugin(pluginID) + 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 = []*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.StopPlugin(plugin.Spec.ID) + } + return true + }) + + //Wait until all loaded plugin process are terminated + m.BlockUntilAllProcessExited() +} diff --git a/src/mod/plugins/typdef.go b/src/mod/plugins/typdef.go new file mode 100644 index 0000000..240742b --- /dev/null +++ b/src/mod/plugins/typdef.go @@ -0,0 +1,40 @@ +package plugins + +import ( + _ "embed" + "net/http" + "os/exec" + "sync" + + "imuslab.com/zoraxy/mod/database" + "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/info/logger" + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" +) + +//go:embed no_img.png +var noImg []byte + +type Plugin struct { + RootDir string //The root directory of the plugin + Spec *zoraxyPlugin.IntroSpect //The plugin specification + Enabled bool //Whether the plugin is enabled + + //Runtime + AssignedPort int //The assigned port for the plugin + uiProxy *dpcore.ReverseProxy //The reverse proxy for the plugin UI + process *exec.Cmd //The process of the plugin +} + +type ManagerOptions struct { + PluginDir string + SystemConst *zoraxyPlugin.RuntimeConstantValue + Database *database.Database + Logger *logger.Logger + CSRFTokenGen func(*http.Request) string //The CSRF token generator function +} + +type Manager struct { + LoadedPlugins sync.Map //Storing *Plugin + Options *ManagerOptions +} diff --git a/src/mod/plugins/uirouter.go b/src/mod/plugins/uirouter.go new file mode 100644 index 0000000..d2ac1c9 --- /dev/null +++ b/src/mod/plugins/uirouter.go @@ -0,0 +1,55 @@ +package plugins + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "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 + } + + upstreamOrigin := "127.0.0.1:" + strconv.Itoa(plugin.AssignedPort) + matchingPath := "/plugin.ui/" + plugin.Spec.ID + + //Rewrite the request path to the plugin UI path + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, matchingPath) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + + //Call the plugin UI handler + plugin.uiProxy.ServeHTTP(w, r, &dpcore.ResponseRewriteRuleSet{ + UseTLS: false, + OriginalHost: r.Host, + ProxyDomain: upstreamOrigin, + NoCache: true, + PathPrefix: matchingPath, + Version: m.Options.SystemConst.ZoraxyVersion, + UpstreamHeaders: [][]string{ + {"X-Zoraxy-Csrf", m.Options.CSRFTokenGen(r)}, + }, + }) +} diff --git a/src/mod/plugins/utils.go b/src/mod/plugins/utils.go new file mode 100644 index 0000000..a4e89a1 --- /dev/null +++ b/src/mod/plugins/utils.go @@ -0,0 +1,82 @@ +package plugins + +import ( + "errors" + "math/rand" + "os" + "path/filepath" + "runtime" + + "imuslab.com/zoraxy/mod/netutils" + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" +) + +/* +Check if the folder contains a valid plugin in either one of the forms + +1. Contain a file that have the same name as its parent directory, either executable or .exe on Windows +2. Contain a start.sh or start.bat file + +Return the path of the plugin entry point if found +*/ +func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) { + info, err := os.Stat(folderpath) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", errors.New("path is not a directory") + } + expectedBinaryPath := filepath.Join(folderpath, filepath.Base(folderpath)) + if runtime.GOOS == "windows" { + expectedBinaryPath += ".exe" + } + + if _, err := os.Stat(expectedBinaryPath); err == nil { + return expectedBinaryPath, nil + } + + if _, err := os.Stat(filepath.Join(folderpath, "start.sh")); err == nil { + return filepath.Join(folderpath, "start.sh"), nil + } + + if _, err := os.Stat(filepath.Join(folderpath, "start.bat")); err == nil { + return filepath.Join(folderpath, "start.bat"), nil + } + + return "", errors.New("No valid entry point found") +} + +// Log logs a message with an optional error +func (m *Manager) Log(message string, err error) { + m.Options.Logger.PrintAndLog("plugin-manager", message, err) +} + +// getRandomPortNumber generates a random port number between 49152 and 65535 +func getRandomPortNumber() int { + portNo := rand.Intn(65535-49152) + 49152 + //Check if the port is already in use + for netutils.CheckIfPortOccupied(portNo) { + portNo = rand.Intn(65535-49152) + 49152 + } + return portNo +} + +func validatePluginSpec(pluginSpec *zoraxyPlugin.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 +} diff --git a/src/mod/plugins/zoraxy_plugin/README.txt b/src/mod/plugins/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/src/mod/plugins/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/src/mod/plugins/zoraxy_plugin/embed_webserver.go b/src/mod/plugins/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..c529e99 --- /dev/null +++ b/src/mod/plugins/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,128 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + 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 +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + 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 { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + 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 new file mode 100644 index 0000000..b316e6d --- /dev/null +++ b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,174 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type CaptureRule struct { + CapturePath string `json:"capture_path"` + IncludeSubPaths bool `json:"include_sub_paths"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Global Capture Settings + + 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 + + */ + 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) + + /* + Always Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + these always applies + */ + 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) + + /* 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 + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + 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 + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + 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() +} diff --git a/src/reverseproxy.go b/src/reverseproxy.go index e25e405..459c49e 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -174,9 +174,15 @@ func ReverseProxtInit() { }() } +// Toggle the reverse proxy service on and off func ReverseProxyHandleOnOff(w http.ResponseWriter, r *http.Request) { - enable, _ := utils.PostPara(r, "enable") //Support root, vdir and subd - if enable == "true" { + enable, err := utils.PostBool(r, "enable") + if err != nil { + utils.SendErrorResponse(w, "enable not defined") + return + } + + if enable { err := dynamicProxyRouter.StartProxyService() if err != nil { utils.SendErrorResponse(w, err.Error()) 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 d237b8a..6f20c93 100644 --- a/src/start.go +++ b/src/start.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/gorilla/csrf" "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/acme" "imuslab.com/zoraxy/mod/auth" @@ -26,6 +27,8 @@ 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/plugins/zoraxy_plugin" "imuslab.com/zoraxy/mod/sshprox" "imuslab.com/zoraxy/mod/statistic" "imuslab.com/zoraxy/mod/statistic/analytic" @@ -317,6 +320,28 @@ func startupSequence() { log.Fatal(err) } + /* + Plugin Manager + */ + + pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{ + PluginDir: "./plugins", + SystemConst: &zoraxy_plugin.RuntimeConstantValue{ + ZoraxyVersion: SYSTEM_VERSION, + ZoraxyUUID: nodeUUID, + }, + Database: sysdb, + Logger: SystemWideLogger, + CSRFTokenGen: func(r *http.Request) string { + return csrf.Token(r) + }, + }) + + 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) @@ -364,6 +389,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/access.html b/src/web/components/access.html index 6a59453..4ed89fa 100644 --- a/src/web/components/access.html +++ b/src/web/components/access.html @@ -694,6 +694,7 @@ IP Access Count + Country of Origin Blacklist @@ -1489,15 +1490,30 @@ //Load the summary to ip access table function initBlacklistQuickBanTable(){ - $.get("/api/stats/summary", function(data){ - initIpAccessTable(data.RequestClientIp); + $.get("/api/quickban/list", function(data){ + //Convert the data to a dictionary + var ipAccessCounts = {}; + access_ip_country_map = {}; + data.forEach(function(entry){ + ipAccessCounts[entry.IpAddr] = entry.Count + access_ip_country_map[entry.IpAddr] = entry.CountryCode; + }); + initIpAccessTable(ipAccessCounts); }) } initBlacklistQuickBanTable(); + function getCountryISOFromQuickBan(ip){ + if (access_ip_country_map[ip] === "") { + return "LAN / Reserved"; + } + return access_ip_country_map[ip]; + } + var blacklist_entriesPerPage = 30; var blacklist_currentPage = 1; var blacklist_totalPages = 0; + var access_ip_country_map = {}; function initIpAccessTable(ipAccessCounts){ blacklist_totalPages = Math.ceil(Object.keys(ipAccessCounts).length / blacklist_entriesPerPage); @@ -1533,6 +1549,7 @@ var row = $("").appendTo(tableBody); $("").text(ip).appendTo(row); $("").text(accessCount).appendTo(row); + $("").text(getCountryISOFromQuickBan(ip)).appendTo(row); if (ipInBlacklist(ip)){ $("").html(``).appendTo(row); }else{ @@ -1542,7 +1559,7 @@ if (slicedEntries.length == 0){ var row = $("").appendTo(tableBody); - $("").html(` + $("").html(` There are no HTTP requests recorded today `).appendTo(row); diff --git a/src/web/components/gan.html b/src/web/components/gan.html index 12402fa..4c89b49 100644 --- a/src/web/components/gan.html +++ b/src/web/components/gan.html @@ -3,6 +3,10 @@

Global Area Network

Virtual Network Hub that allows all networked devices to communicate as if they all reside in the same physical data center or cloud region

+
+ Deprecation Notice +

Global Area Network will be deprecating in v3.2.x and moved to Plugin

+
diff --git a/src/web/components/plugincontext.html b/src/web/components/plugincontext.html new file mode 100644 index 0000000..f8e2bde --- /dev/null +++ b/src/web/components/plugincontext.html @@ -0,0 +1,62 @@ +
+ +
+ \ No newline at end of file diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html new file mode 100644 index 0000000..dfb0710 --- /dev/null +++ b/src/web/components/plugins.html @@ -0,0 +1,167 @@ +
+
+

Plugins

+

Add custom features to your Zoraxy!

+
+
+
Experimental Feature
+

This feature is experimental and may not work as expected. Use with caution.

+
+ + + + + + + + + + + +
Plugin NameDescriptionsCatergoryAction
+
+ + + + + diff --git a/src/web/index.html b/src/web/index.html index 2880c16..8c71281 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -93,6 +93,15 @@ Utilities + + Plugins Manager + + + + + No Plugins Installed + +
@@ -138,7 +147,10 @@
- + +
+ +
@@ -149,6 +161,12 @@
+ + +
+ + +
@@ -240,7 +258,26 @@ if (window.location.hash.length > 1){ let tabID = window.location.hash.substr(1); - openTabById(tabID); + if (tabID.startsWith("{")) { + tabID = decodeURIComponent(tabID); + //Zoraxy v3.2.x plugin context window + try { + let parsedData = JSON.parse(tabID); + tabID = parsedData.tabID; + + //Open the plugin context window + if (tabID == "pluginContextWindow"){ + let pluginID = parsedData.pluginID; + let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`); + openTabById(tabID, button); + loadPluginUIContextIfAvailable(); + } + } catch (e) { + console.error("Invalid JSON data:", e); + } + }else{ + openTabById(tabID); + } }else{ openTabById("status"); } @@ -251,7 +288,7 @@ $("#mainmenu").find(".item").each(function(){ $(this).on("click", function(event){ let tabid = $(this).attr("tag"); - openTabById(tabid); + openTabById(tabid, $(this)); }); }); @@ -276,13 +313,19 @@ if ($(".sideWrapper").is(":visible")){ $(".sideWrapper iframe")[0].contentWindow.setDarkTheme(false); } + + if ($("#pluginContextLoader").is(":visible")){ + $("#pluginContextLoader")[0].contentWindow.setDarkTheme(false); + } }else{ setDarkTheme(true); //Check if the snippet iframe is opened. If yes, set the dark theme to the iframe if ($(".sideWrapper").is(":visible")){ $(".sideWrapper iframe")[0].contentWindow.setDarkTheme(true); } - + if ($("#pluginContextLoader").is(":visible")){ + $("#pluginContextLoader")[0].contentWindow.setDarkTheme(true); + } } } @@ -301,8 +344,12 @@ //Select and open a tab by its tag id let tabSwitchEventBind = {}; //Bind event to tab switch by tabid - function openTabById(tabID){ - let targetBtn = getTabButtonById(tabID); + function openTabById(tabID, object=undefined){ + let targetBtn = object; + if (object == undefined){ + //Search tab by its tap id + targetBtn = getTabButtonById(tabID); + } if (targetBtn == undefined){ alert("Invalid tabid given"); return; @@ -323,7 +370,19 @@ },100) }); $('html,body').animate({scrollTop: 0}, 'fast'); - window.location.hash = tabID; + + if (tabID == "pluginContextWindow"){ + let statePayload = { + tabID: tabID, + pluginID: $(targetBtn).attr("pluginid") + } + + window.location.hash = JSON.stringify(statePayload); + loadPluginUIContextIfAvailable(); + }else{ + window.location.hash = tabID; + } + } $(window).on("resize", function(){ @@ -408,7 +467,7 @@ Toggles for side wrapper */ - function showSideWrapper(scriptpath=""){ + function showSideWrapper(scriptpath="", extendedMode=false){ if (scriptpath != ""){ $(".sideWrapper iframe").attr("src", scriptpath); } @@ -416,6 +475,12 @@ if ($(".sideWrapper .content").transition("is animating") || $(".sideWrapper .content").transition("is visible")){ return } + + if (extendedMode){ + $(".sideWrapper").addClass("extendedMode"); + }else{ + $(".sideWrapper").removeClass("extendedMode"); + } $(".sideWrapper").show(); $(".sideWrapper .fadingBackground").fadeIn("fast"); $(".sideWrapper .content").transition('slide left in', 300); diff --git a/src/web/main.css b/src/web/main.css index 3989f5b..b69c0f6 100644 --- a/src/web/main.css +++ b/src/web/main.css @@ -188,6 +188,10 @@ body{ z-index: 10; } +.sideWrapper.extendedMode{ + max-width: calc(80% - 1em); +} + .sideWrapper .content{ height: 100%; width: 100%;