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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
You can change the network name and description below. The name and description is only for easy management purpose and will not effect the network operation.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
Auth
+
Address
+
Name
+
Managed IP
+
Authorized Since
+
Version
+
Remove
+
+
+
+
+
+
+
+
+
+
+
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.
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 ID
+
Name
+
Description
+
Subnet (Assign Range)
+
Nodes
+
Actions
+
+
+
+
+
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.