From cd822ed904af5dc435a84497977da66f3580f8a6 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Fri, 30 May 2025 21:21:08 +0800 Subject: [PATCH] Added dynamic capture example - Added dynamic capture plugin example - Added dynamic capture plugin doc - Removed ztnc from plugin example --- .../2. Architecture/6. Compile a Plugin.md | 15 + .../3. Static Capture Example.md | 5 +- .../4. Dynamic Capture Example.md | 352 ++++++++ .../image-20250530205430254.png | Bin 0 -> 48039 bytes .../1. What is Zoraxy Plugin.html | 6 + .../1. Introduction/2. Getting Started.html | 6 + .../1. Introduction/3. Installing Plugin.html | 6 + .../1. Introduction/4. Enable Plugins.html | 6 + .../5. Viewing Plugin Info.html | 6 + .../1. Plugin Architecture.html | 6 + .../html/2. Architecture/2. Introspect.html | 6 + .../html/2. Architecture/3. Configure.html | 6 + .../2. Architecture/4. Capture Modes.html | 6 + .../html/2. Architecture/5. Plugin UI.html | 6 + .../2. Architecture/6. Compile a Plugin.html | 200 +++++ .../3. Basic Examples/1. Hello World.html | 6 + .../3. Basic Examples/2. RESTful Example.html | 6 + .../3. Static Capture Example.html | 19 + .../4. Dynamic Capture Example.html | 673 ++++++++++++++++ .../image-20250530205430254.png | Bin 0 -> 48039 bytes docs/plugins/html/index.html | 6 + docs/plugins/index.json | 10 + .../plugins/dynamic-capture-example/go.mod | 3 + .../plugins/dynamic-capture-example/main.go | 131 +++ .../mod/zoraxy_plugin/README.txt | 0 .../mod/zoraxy_plugin/dev_webserver.go | 0 .../mod/zoraxy_plugin/dynamic_router.go | 0 .../mod/zoraxy_plugin/embed_webserver.go | 18 + .../mod/zoraxy_plugin/static_router.go | 0 .../mod/zoraxy_plugin/zoraxy_plugin.go | 0 example/plugins/ztnc/README.md | 11 - example/plugins/ztnc/go.mod | 11 - example/plugins/ztnc/go.sum | 30 - example/plugins/ztnc/icon.png | Bin 7839 -> 0 bytes example/plugins/ztnc/icon.psd | Bin 127851 -> 0 bytes example/plugins/ztnc/main.go | 83 -- example/plugins/ztnc/mod/database/database.go | 146 ---- .../ztnc/mod/database/database_core.go | 70 -- .../ztnc/mod/database/database_openwrt.go | 196 ----- .../ztnc/mod/database/dbbolt/dbbolt.go | 141 ---- .../ztnc/mod/database/dbbolt/dbbolt_test.go | 67 -- .../plugins/ztnc/mod/database/dbinc/dbinc.go | 39 - .../ztnc/mod/database/dbleveldb/dbleveldb.go | 152 ---- .../mod/database/dbleveldb/dbleveldb_test.go | 141 ---- example/plugins/ztnc/mod/ganserv/authkey.go | 80 -- .../plugins/ztnc/mod/ganserv/authkeyLinux.go | 37 - .../plugins/ztnc/mod/ganserv/authkeyWin.go | 62 -- example/plugins/ztnc/mod/ganserv/ganserv.go | 130 --- example/plugins/ztnc/mod/ganserv/handlers.go | 504 ------------ example/plugins/ztnc/mod/ganserv/network.go | 39 - .../plugins/ztnc/mod/ganserv/network_test.go | 55 -- example/plugins/ztnc/mod/ganserv/utils.go | 55 -- example/plugins/ztnc/mod/ganserv/zerotier.go | 669 ---------------- example/plugins/ztnc/mod/utils/conv.go | 105 --- example/plugins/ztnc/mod/utils/template.go | 19 - example/plugins/ztnc/mod/utils/utils.go | 202 ----- example/plugins/ztnc/start.go | 69 -- example/plugins/ztnc/web/details.html | 752 ------------------ example/plugins/ztnc/web/index.html | 267 ------- 59 files changed, 1502 insertions(+), 4134 deletions(-) create mode 100644 docs/plugins/docs/2. Architecture/6. Compile a Plugin.md create mode 100644 docs/plugins/docs/3. Basic Examples/4. Dynamic Capture Example.md create mode 100644 docs/plugins/docs/3. Basic Examples/img/4. Dynamic Capture Example/image-20250530205430254.png create mode 100644 docs/plugins/html/2. Architecture/6. Compile a Plugin.html create mode 100644 docs/plugins/html/3. Basic Examples/4. Dynamic Capture Example.html create mode 100644 docs/plugins/html/3. Basic Examples/img/4. Dynamic Capture Example/image-20250530205430254.png create mode 100644 example/plugins/dynamic-capture-example/go.mod create mode 100644 example/plugins/dynamic-capture-example/main.go rename example/plugins/{ztnc => dynamic-capture-example}/mod/zoraxy_plugin/README.txt (100%) rename example/plugins/{ztnc => dynamic-capture-example}/mod/zoraxy_plugin/dev_webserver.go (100%) rename example/plugins/{ztnc => dynamic-capture-example}/mod/zoraxy_plugin/dynamic_router.go (100%) rename example/plugins/{ztnc => dynamic-capture-example}/mod/zoraxy_plugin/embed_webserver.go (88%) rename example/plugins/{ztnc => dynamic-capture-example}/mod/zoraxy_plugin/static_router.go (100%) rename example/plugins/{ztnc => dynamic-capture-example}/mod/zoraxy_plugin/zoraxy_plugin.go (100%) delete mode 100644 example/plugins/ztnc/README.md delete mode 100644 example/plugins/ztnc/go.mod delete mode 100644 example/plugins/ztnc/go.sum delete mode 100644 example/plugins/ztnc/icon.png delete mode 100644 example/plugins/ztnc/icon.psd delete mode 100644 example/plugins/ztnc/main.go delete mode 100644 example/plugins/ztnc/mod/database/database.go delete mode 100644 example/plugins/ztnc/mod/database/database_core.go delete mode 100644 example/plugins/ztnc/mod/database/database_openwrt.go delete mode 100644 example/plugins/ztnc/mod/database/dbbolt/dbbolt.go delete mode 100644 example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go delete mode 100644 example/plugins/ztnc/mod/database/dbinc/dbinc.go delete mode 100644 example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go delete mode 100644 example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go delete mode 100644 example/plugins/ztnc/mod/ganserv/authkey.go delete mode 100644 example/plugins/ztnc/mod/ganserv/authkeyLinux.go delete mode 100644 example/plugins/ztnc/mod/ganserv/authkeyWin.go delete mode 100644 example/plugins/ztnc/mod/ganserv/ganserv.go delete mode 100644 example/plugins/ztnc/mod/ganserv/handlers.go delete mode 100644 example/plugins/ztnc/mod/ganserv/network.go delete mode 100644 example/plugins/ztnc/mod/ganserv/network_test.go delete mode 100644 example/plugins/ztnc/mod/ganserv/utils.go delete mode 100644 example/plugins/ztnc/mod/ganserv/zerotier.go delete mode 100644 example/plugins/ztnc/mod/utils/conv.go delete mode 100644 example/plugins/ztnc/mod/utils/template.go delete mode 100644 example/plugins/ztnc/mod/utils/utils.go delete mode 100644 example/plugins/ztnc/start.go delete mode 100644 example/plugins/ztnc/web/details.html delete mode 100644 example/plugins/ztnc/web/index.html diff --git a/docs/plugins/docs/2. Architecture/6. Compile a Plugin.md b/docs/plugins/docs/2. Architecture/6. Compile a Plugin.md new file mode 100644 index 0000000..49fbc2c --- /dev/null +++ b/docs/plugins/docs/2. Architecture/6. Compile a Plugin.md @@ -0,0 +1,15 @@ +# Compile a Plugin + +A plugin is basically a go program with a HTTP Server / Listener. The steps required to build a plugin is identical as building a ordinary go program. + +```bash +# Assuming you are currently inside the root folder of your plugin +go mod tidy +go build + +# Validate if the plugin is correctly build using -introspect flag +./{{your_plugin_name}} -introspect + +# You should see your plugin information printed to STDOUT as JSON string +``` + diff --git a/docs/plugins/docs/3. Basic Examples/3. Static Capture Example.md b/docs/plugins/docs/3. Basic Examples/3. Static Capture Example.md index 166af0b..c7549a6 100644 --- a/docs/plugins/docs/3. Basic Examples/3. Static Capture Example.md +++ b/docs/plugins/docs/3. Basic Examples/3. Static Capture Example.md @@ -76,7 +76,7 @@ pathRouter.RegisterStaticCaptureHandle(STATIC_CAPTURE_INGRESS, http.DefaultServe The `SetDefaultHandler` is used to handle exceptions where a request is forwarded to your plugin but it cannot be handled by any of your registered path handlers. This is usually an implementation bug on the plugin side and you can add some help message or debug log to this function if needed. - +The `RegisterStaticCaptureHandle` is used to register the static capture ingress endpoint, so Zoraxy knows where to forward the HTTP request when it thinks your plugin shall be the one handling the request. In this example, `/s_capture` is used for static capture endpoint. --- @@ -261,4 +261,5 @@ Request URI: /test_b --- -Enjoy exploring static capture in Zoraxy! \ No newline at end of file +Enjoy exploring static capture in Zoraxy! + diff --git a/docs/plugins/docs/3. Basic Examples/4. Dynamic Capture Example.md b/docs/plugins/docs/3. Basic Examples/4. Dynamic Capture Example.md new file mode 100644 index 0000000..329e72c --- /dev/null +++ b/docs/plugins/docs/3. Basic Examples/4. Dynamic Capture Example.md @@ -0,0 +1,352 @@ +# Dynamic Capture Example +Last Update: 29/05/2025 + +--- + + +This example demonstrates how to use dynamic capture in Zoraxy plugins. Dynamic capture allows you to intercept requests based on real-time conditions, so you can program your plugin in a way that it can decided if it want to handle the request or not. + +**Notes: This example assumes you have already read Hello World and Stataic Capture Example.** + +Lets dive in! + +--- + +## 1. Create the plugin folder structure + +Follow the same steps as the Hello World example to set up the plugin folder structure. Refer to the Hello World example sections 1 to 5 for details. + +--- + +## 2. Define Introspect + +The introspect configuration specifies the dynamic capture sniff and ingress paths for your plugin. + +```go +runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: "org.aroz.zoraxy.dynamic-capture-example", + Name: "Dynamic Capture Example", + Author: "aroz.org", + AuthorContact: "https://aroz.org", + Description: "This is an example plugin for Zoraxy that demonstrates how to use dynamic captures.", + URL: "https://zoraxy.aroz.org", + Type: plugin.PluginType_Router, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + DynamicCaptureSniff: "/d_sniff", + DynamicCaptureIngress: "/d_capture", + + UIPath: UI_PATH, +}) +if err != nil { + panic(err) +} +``` + +Note the `DynamicCaptureSniff` and `DynamicCaptureIngress`. These paths define the sniffing and capturing behavior for dynamic requests. The sniff path is used to evaluate whether a request should be intercepted, while the ingress path handles the intercepted requests. + +--- + +## 3. Register Dynamic Capture Handlers + +Dynamic capture handlers are used to process requests that match specific conditions. + +```go +pathRouter := plugin.NewPathRouter() +pathRouter.SetDebugPrintMode(true) + +pathRouter.RegisterDynamicSniffHandler("/d_sniff", http.DefaultServeMux, func(dsfr *plugin.DynamicSniffForwardRequest) plugin.SniffResult { + if strings.HasPrefix(dsfr.RequestURI, "/foobar") { + fmt.Println("Accepting request with UUID: " + dsfr.GetRequestUUID()) + return plugin.SniffResultAccpet + } + fmt.Println("Skipping request with UUID: " + dsfr.GetRequestUUID()) + return plugin.SniffResultSkip +}) + +pathRouter.RegisterDynamicCaptureHandle("/d_capture", http.DefaultServeMux, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Welcome to the dynamic capture handler!\n\nRequest Info:\n")) + w.Write([]byte("Request URI: " + r.RequestURI + "\n")) + w.Write([]byte("Request Method: " + r.Method + "\n")) + w.Write([]byte("Request Headers:\n")) + headers := make([]string, 0, len(r.Header)) + for key := range r.Header { + headers = append(headers, key) + } + sort.Strings(headers) + for _, key := range headers { + for _, value := range r.Header[key] { + w.Write([]byte(fmt.Sprintf("%s: %s\n", key, value))) + } + } +}) +``` + +The `RegisterDynamicSniffHandler` evaluates incoming requests, while the `RegisterDynamicCaptureHandle` processes the intercepted requests. + +### Sniffing Logic + +If a module registered a dynamic capture path, Zoraxy will forward the request headers as `DynamicSniffForwardRequest` (`dsfr`) object to all the plugins that is assigned to this tag. And in each of the plugins, a dedicated logic will take in the object and "think" if they want to handle the request. You can get the following information from the dsfr object by directly accessing the members of it. + +```go +type DynamicSniffForwardRequest struct { + Method string `json:"method"` + Hostname string `json:"hostname"` + URL string `json:"url"` + Header map[string][]string `json:"header"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + RequestURI string `json:"request_uri"` + Proto string `json:"proto"` + ProtoMajor int `json:"proto_major"` + ProtoMinor int `json:"proto_minor"` +} +``` + +You can also use the `GetRequest()` function to get the `*http.Request` object or `GetRequestUUID()` to get a `string` value that is a UUID corresponding to this request for later matching with the incoming, forwarded request. + +**Note that since all request will pass through the sniffing function in your plugin, do not implement any blocking logic in your sniffing function, otherwise this will slow down all traffic going through the HTTP proxy rule with the plugin enabled.** + +In the sniffing stage, you can choose to either return `ControlStatusCode_CAPTURED`, where Zoraxy will forward the request to your plugin `DynamicCaptureIngress` endpoint, or `ControlStatusCode_UNHANDLED`, where Zoraxy will pass on the request to the next dynamic handling plugin or if there are no more plugins to handle the routing, to the upstream server. + +### Capture Handling + +The capture handling is where Zoraxy formally forward you the HTTP request the client is requesting. In this situation, you must response the request by properly handling the ` http.Request` by writing to the `http.ResponseWriter`. + +If there is a need to match the sniffing to the capture handling logic (Let say you want to design your plugin to run some kind of pre-processing before the actual request came in), you can use the `X-Zoraxy-Requestid` header in the HTTP request. This is the same UUID as the one you get from `dsfr.GetRequestUUID()` in the sniffing stage if they are the same request object on Zoraxy side. + +The http request that Zoraxy forwards to the plugin capture handling endpoint contains header like these. + +```html +Request URI: /foobar/test +Request Method: GET +Request Headers: +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 +Accept-Encoding: gzip, deflate, br, zstd +(more fileds) +X-Forwarded-For: 127.0.0.1 +X-Forwarded-Proto: https +X-Real-Ip: 127.0.0.1 +X-Zoraxy-Requestid: d00619b8-f39e-4c04-acd8-c3a6f55b1566 +``` + +You can extract the `X-Zoraxy-Requestid` value from the request header and do your matching for implementing your function if needed. + +--- + +## 4. Render Debug UI + +This UI is used help validate the management Web UI is correctly shown in Zoraxy webmin interface. You should implement the required management interface for your plugin here. + +```go +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") +} +``` + + + +--- + +## 5. Full Code + +Here is the complete code for the dynamic capture example: + +```go +package main + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + plugin "example.com/zoraxy/dynamic-capture-example/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.dynamic-capture-example" + UI_PATH = "/debug" + STATIC_CAPTURE_INGRESS = "/s_capture" +) + +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.dynamic-capture-example", + Name: "Dynamic Capture Example", + Author: "aroz.org", + AuthorContact: "https://aroz.org", + Description: "This is an example plugin for Zoraxy that demonstrates how to use dynamic captures.", + URL: "https://zoraxy.aroz.org", + Type: plugin.PluginType_Router, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + DynamicCaptureSniff: "/d_sniff", + DynamicCaptureIngress: "/d_capture", + + UIPath: UI_PATH, + + /* + SubscriptionPath: "/subept", + SubscriptionsEvents: []plugin.SubscriptionEvent{ + */ + }) + if err != nil { + //Terminate or enter standalone mode here + panic(err) + } + + // Setup the path router + pathRouter := plugin.NewPathRouter() + pathRouter.SetDebugPrintMode(true) + + /* + Dynamic Captures + */ + pathRouter.RegisterDynamicSniffHandler("/d_sniff", http.DefaultServeMux, func(dsfr *plugin.DynamicSniffForwardRequest) plugin.SniffResult { + //In this example, we want to capture all URI + //that start with /foobar and forward it to the dynamic capture handler + if strings.HasPrefix(dsfr.RequestURI, "/foobar") { + reqUUID := dsfr.GetRequestUUID() + fmt.Println("Accepting request with UUID: " + reqUUID) + + // Print all the values of the request + fmt.Println("Method:", dsfr.Method) + fmt.Println("Hostname:", dsfr.Hostname) + fmt.Println("URL:", dsfr.URL) + fmt.Println("Header:") + for key, values := range dsfr.Header { + for _, value := range values { + fmt.Printf(" %s: %s\n", key, value) + } + } + fmt.Println("RemoteAddr:", dsfr.RemoteAddr) + fmt.Println("Host:", dsfr.Host) + fmt.Println("RequestURI:", dsfr.RequestURI) + fmt.Println("Proto:", dsfr.Proto) + fmt.Println("ProtoMajor:", dsfr.ProtoMajor) + fmt.Println("ProtoMinor:", dsfr.ProtoMinor) + + // We want to handle this request, reply with aSniffResultAccept + return plugin.SniffResultAccpet + } + + // If the request URI does not match, we skip this request + fmt.Println("Skipping request with UUID: " + dsfr.GetRequestUUID()) + return plugin.SniffResultSkip + }) + pathRouter.RegisterDynamicCaptureHandle("/d_capture", http.DefaultServeMux, func(w http.ResponseWriter, r *http.Request) { + // This is the dynamic capture handler where it actually captures and handle the request + w.WriteHeader(http.StatusOK) + w.Write([]byte("Welcome to the dynamic capture handler!")) + + // Print all the request info to the response writer + w.Write([]byte("\n\nRequest Info:\n")) + w.Write([]byte("Request URI: " + r.RequestURI + "\n")) + w.Write([]byte("Request Method: " + r.Method + "\n")) + w.Write([]byte("Request Headers:\n")) + headers := make([]string, 0, len(r.Header)) + for key := range r.Header { + headers = append(headers, key) + } + sort.Strings(headers) + for _, key := range headers { + for _, value := range r.Header[key] { + w.Write([]byte(fmt.Sprintf("%s: %s\n", key, value))) + } + } + }) + + http.HandleFunc(UI_PATH+"/", RenderDebugUI) + fmt.Println("Dynamic capture example started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port)) + http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) +} + +// 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") +} + +``` + +--- + +## 6. Expected Output + +To enable the plugin, add the plugin to one of the tags and assign the tag to your HTTP Proxy Rule. Here is an example of assigning the plugin to the "debug" tag and assigning it to a localhost loopback HTTP proxy rule. + +When the plugin is running, requests matching the sniff conditions will be intercepted and processed by the dynamic capture handler. + +If everything is correctly setup, you should see the following page when requesting any URL with prefix `(your_HTTP_proxy_rule_hostname)/foobar` + +![image-20250530205430254](img/4. Dynamic Capture Example/image-20250530205430254.png) + + + +Example terminal output for requesting `/foobar/*`: + +``` +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Request captured by dynamic sniff path: /d_sniff/ +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Header: +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Method: GET +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Hostname: a.localhost +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] URL: /foobar/test +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Accepting request with UUID: 8c916c58-0d6a-4d11-a2f0-f29d3d984509 +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Fetch-Dest: document +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Accept-Encoding: gzip, deflate, br, zstd +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Accept-Language: zh-TW,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Cache-Control: max-age=0 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Fetch-User: ?1 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Upgrade-Insecure-Requests: 1 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Priority: u=0, i +[2025-05-30 20:44:26.143149] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Ch-Ua-Mobile: ?0 +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Ch-Ua: "Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99" +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Ch-Ua-Platform: "Windows" +[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Fetch-Site: none +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Sec-Fetch-Mode: navigate +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] RemoteAddr: [::1]:54522 +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Host: a.localhost +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] RequestURI: /foobar/test +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Proto: HTTP/2.0 +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] ProtoMajor: 2 +[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] ProtoMinor: 0 +``` + +--- + +Now you know how to develop a plugin in Zoraxy that handles special routings! \ No newline at end of file diff --git a/docs/plugins/docs/3. Basic Examples/img/4. Dynamic Capture Example/image-20250530205430254.png b/docs/plugins/docs/3. Basic Examples/img/4. Dynamic Capture Example/image-20250530205430254.png new file mode 100644 index 0000000000000000000000000000000000000000..3c264f2685686edf42986745582bbf092f4e8bc3 GIT binary patch literal 48039 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 Z0fV?nxvhzJ>raARNnTAZ@7Zhb{{fgQRb2o8 literal 0 HcmV?d00001 diff --git a/docs/plugins/html/1. Introduction/1. What is Zoraxy Plugin.html b/docs/plugins/html/1. Introduction/1. What is Zoraxy Plugin.html index cebfa6b..a0057cd 100644 --- a/docs/plugins/html/1. Introduction/1. What is Zoraxy Plugin.html +++ b/docs/plugins/html/1. Introduction/1. What is Zoraxy Plugin.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/1. Introduction/2. Getting Started.html b/docs/plugins/html/1. Introduction/2. Getting Started.html index c15e0be..bce7229 100644 --- a/docs/plugins/html/1. Introduction/2. Getting Started.html +++ b/docs/plugins/html/1. Introduction/2. Getting Started.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/1. Introduction/3. Installing Plugin.html b/docs/plugins/html/1. Introduction/3. Installing Plugin.html index 3636ba4..c213ed7 100644 --- a/docs/plugins/html/1. Introduction/3. Installing Plugin.html +++ b/docs/plugins/html/1. Introduction/3. Installing Plugin.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/1. Introduction/4. Enable Plugins.html b/docs/plugins/html/1. Introduction/4. Enable Plugins.html index fa637f4..071895f 100644 --- a/docs/plugins/html/1. Introduction/4. Enable Plugins.html +++ b/docs/plugins/html/1. Introduction/4. Enable Plugins.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/1. Introduction/5. Viewing Plugin Info.html b/docs/plugins/html/1. Introduction/5. Viewing Plugin Info.html index d354dda..1f6b3cf 100644 --- a/docs/plugins/html/1. Introduction/5. Viewing Plugin Info.html +++ b/docs/plugins/html/1. Introduction/5. Viewing Plugin Info.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/2. Architecture/1. Plugin Architecture.html b/docs/plugins/html/2. Architecture/1. Plugin Architecture.html index 7a6c02d..9ab5cb5 100644 --- a/docs/plugins/html/2. Architecture/1. Plugin Architecture.html +++ b/docs/plugins/html/2. Architecture/1. Plugin Architecture.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/2. Architecture/2. Introspect.html b/docs/plugins/html/2. Architecture/2. Introspect.html index 5b6904d..af8afdd 100644 --- a/docs/plugins/html/2. Architecture/2. Introspect.html +++ b/docs/plugins/html/2. Architecture/2. Introspect.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/2. Architecture/3. Configure.html b/docs/plugins/html/2. Architecture/3. Configure.html index 12b26c9..5287c12 100644 --- a/docs/plugins/html/2. Architecture/3. Configure.html +++ b/docs/plugins/html/2. Architecture/3. Configure.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/2. Architecture/4. Capture Modes.html b/docs/plugins/html/2. Architecture/4. Capture Modes.html index 9420a3c..6de5a9f 100644 --- a/docs/plugins/html/2. Architecture/4. Capture Modes.html +++ b/docs/plugins/html/2. Architecture/4. Capture Modes.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/2. Architecture/5. Plugin UI.html b/docs/plugins/html/2. Architecture/5. Plugin UI.html index 6b81c60..88d8f1d 100644 --- a/docs/plugins/html/2. Architecture/5. Plugin UI.html +++ b/docs/plugins/html/2. Architecture/5. Plugin UI.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/2. Architecture/6. Compile a Plugin.html b/docs/plugins/html/2. Architecture/6. Compile a Plugin.html new file mode 100644 index 0000000..2198d06 --- /dev/null +++ b/docs/plugins/html/2. Architecture/6. Compile a Plugin.html @@ -0,0 +1,200 @@ + + + + + + + + Compile a Plugin | Zoraxy Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

+ Compile a Plugin +

+

+

+ A plugin is basically a go program with a HTTP Server / Listener. The steps required to build a plugin is identical as building a ordinary go program. +

+

+
# Assuming you are currently inside the root folder of your plugin
+go mod tidy
+go build
+
+# Validate if the plugin is correctly build using -introspect flag
+./{{your_plugin_name}} -introspect
+
+# You should see your plugin information printed to STDOUT as JSON string
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Zoraxy © tobychui + + 2025 + +
+
+
+ + + + \ No newline at end of file diff --git a/docs/plugins/html/3. Basic Examples/1. Hello World.html b/docs/plugins/html/3. Basic Examples/1. Hello World.html index 7d58ba0..c36a8bb 100644 --- a/docs/plugins/html/3. Basic Examples/1. Hello World.html +++ b/docs/plugins/html/3. Basic Examples/1. Hello World.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/3. Basic Examples/2. RESTful Example.html b/docs/plugins/html/3. Basic Examples/2. RESTful Example.html index 094994a..3e8072d 100644 --- a/docs/plugins/html/3. Basic Examples/2. RESTful Example.html +++ b/docs/plugins/html/3. Basic Examples/2. RESTful Example.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/html/3. Basic Examples/3. Static Capture Example.html b/docs/plugins/html/3. Basic Examples/3. Static Capture Example.html index eddfbf1..4dd426b 100644 --- a/docs/plugins/html/3. Basic Examples/3. Static Capture Example.html +++ b/docs/plugins/html/3. Basic Examples/3. Static Capture Example.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index @@ -275,6 +281,19 @@ pathRouter.RegisterStaticCaptureHandle(STATIC_CAPTURE_INGRESS, http.DefaultServe is used to handle exceptions where a request is forwarded to your plugin but it cannot be handled by any of your registered path handlers. This is usually an implementation bug on the plugin side and you can add some help message or debug log to this function if needed.

+

+

+ The + + RegisterStaticCaptureHandle + + is used to register the static capture ingress endpoint, so Zoraxy knows where to forward the HTTP request when it thinks your plugin shall be the one handling the request. In this example, + + /s_capture + + is used for static capture endpoint. +

+

4. Implement Handlers diff --git a/docs/plugins/html/3. Basic Examples/4. Dynamic Capture Example.html b/docs/plugins/html/3. Basic Examples/4. Dynamic Capture Example.html new file mode 100644 index 0000000..1fdaaae --- /dev/null +++ b/docs/plugins/html/3. Basic Examples/4. Dynamic Capture Example.html @@ -0,0 +1,673 @@ + + + + + + + + Dynamic Capture Example | Zoraxy Documentation + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+

+ Dynamic Capture Example +

+

+

+ Last Update: 29/05/2025 +

+

+
+

+

+ This example demonstrates how to use dynamic capture in Zoraxy plugins. Dynamic capture allows you to intercept requests based on real-time conditions, so you can program your plugin in a way that it can decided if it want to handle the request or not. +

+

+

+

+ + Notes: This example assumes you have already read Hello World and Stataic Capture Example. + +

+

+

+

+ Lets dive in! +

+

+
+

+ 1. Create the plugin folder structure +

+

+

+ Follow the same steps as the Hello World example to set up the plugin folder structure. Refer to the Hello World example sections 1 to 5 for details. +

+

+
+

+ 2. Define Introspect +

+

+

+ The introspect configuration specifies the dynamic capture sniff and ingress paths for your plugin. +

+

+
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
+    ID:            "org.aroz.zoraxy.dynamic-capture-example",
+    Name:          "Dynamic Capture Example",
+    Author:        "aroz.org",
+    AuthorContact: "https://aroz.org",
+    Description:   "This is an example plugin for Zoraxy that demonstrates how to use dynamic captures.",
+    URL:           "https://zoraxy.aroz.org",
+    Type:          plugin.PluginType_Router,
+    VersionMajor:  1,
+    VersionMinor:  0,
+    VersionPatch:  0,
+
+    DynamicCaptureSniff:   "/d_sniff",
+    DynamicCaptureIngress: "/d_capture",
+
+    UIPath: UI_PATH,
+})
+if err != nil {
+    panic(err)
+}
+
+

+

+ Note the + + DynamicCaptureSniff + + and + + DynamicCaptureIngress + + . These paths define the sniffing and capturing behavior for dynamic requests. The sniff path is used to evaluate whether a request should be intercepted, while the ingress path handles the intercepted requests. +

+

+
+

+ 3. Register Dynamic Capture Handlers +

+

+

+ Dynamic capture handlers are used to process requests that match specific conditions. +

+

+
pathRouter := plugin.NewPathRouter()
+pathRouter.SetDebugPrintMode(true)
+
+pathRouter.RegisterDynamicSniffHandler("/d_sniff", http.DefaultServeMux, func(dsfr *plugin.DynamicSniffForwardRequest) plugin.SniffResult {
+    if strings.HasPrefix(dsfr.RequestURI, "/foobar") {
+        fmt.Println("Accepting request with UUID: " + dsfr.GetRequestUUID())
+        return plugin.SniffResultAccpet
+    }
+    fmt.Println("Skipping request with UUID: " + dsfr.GetRequestUUID())
+    return plugin.SniffResultSkip
+})
+
+pathRouter.RegisterDynamicCaptureHandle("/d_capture", http.DefaultServeMux, func(w http.ResponseWriter, r *http.Request) {
+    w.WriteHeader(http.StatusOK)
+    w.Write([]byte("Welcome to the dynamic capture handler!\n\nRequest Info:\n"))
+    w.Write([]byte("Request URI: " + r.RequestURI + "\n"))
+    w.Write([]byte("Request Method: " + r.Method + "\n"))
+    w.Write([]byte("Request Headers:\n"))
+    headers := make([]string, 0, len(r.Header))
+    for key := range r.Header {
+        headers = append(headers, key)
+    }
+    sort.Strings(headers)
+    for _, key := range headers {
+        for _, value := range r.Header[key] {
+            w.Write([]byte(fmt.Sprintf("%s: %s\n", key, value)))
+        }
+    }
+})
+
+

+

+ The + + RegisterDynamicSniffHandler + + evaluates incoming requests, while the + + RegisterDynamicCaptureHandle + + processes the intercepted requests. +

+

+

+ Sniffing Logic +

+

+ If a module registered a dynamic capture path, Zoraxy will forward the request headers as + + DynamicSniffForwardRequest + + ( + + dsfr + + ) object to all the plugins that is assigned to this tag. And in each of the plugins, a dedicated logic will take in the object and “think” if they want to handle the request. You can get the following information from the dsfr object by directly accessing the members of it. +

+
type DynamicSniffForwardRequest struct {
+	Method     string              `json:"method"`
+	Hostname   string              `json:"hostname"`
+	URL        string              `json:"url"`
+	Header     map[string][]string `json:"header"`
+	RemoteAddr string              `json:"remote_addr"`
+	Host       string              `json:"host"`
+	RequestURI string              `json:"request_uri"`
+	Proto      string              `json:"proto"`
+	ProtoMajor int                 `json:"proto_major"`
+	ProtoMinor int                 `json:"proto_minor"`
+}
+
+

+

+ You can also use the + + GetRequest() + + function to get the + + *http.Request + + object or + + GetRequestUUID() + + to get a + + string + + value that is a UUID corresponding to this request for later matching with the incoming, forwarded request. +

+

+

+

+ + Note that since all request will pass through the sniffing function in your plugin, do not implement any blocking logic in your sniffing function, otherwise this will slow down all traffic going through the HTTP proxy rule with the plugin enabled. + +

+

+

+

+ In the sniffing stage, you can choose to either return + + ControlStatusCode_CAPTURED + + , where Zoraxy will forward the request to your plugin + + DynamicCaptureIngress + + endpoint, or + + ControlStatusCode_UNHANDLED + + , where Zoraxy will pass on the request to the next dynamic handling plugin or if there are no more plugins to handle the routing, to the upstream server. +

+

+

+ Capture Handling +

+

+

+ The capture handling is where Zoraxy formally forward you the HTTP request the client is requesting. In this situation, you must response the request by properly handling the + + http.Request + + by writing to the + + http.ResponseWriter + + . +

+

+

+

+ If there is a need to match the sniffing to the capture handling logic (Let say you want to design your plugin to run some kind of pre-processing before the actual request came in), you can use the + + X-Zoraxy-Requestid + + header in the HTTP request. This is the same UUID as the one you get from + + dsfr.GetRequestUUID() + + in the sniffing stage if they are the same request object on Zoraxy side. +

+

+

+

+ The http request that Zoraxy forwards to the plugin capture handling endpoint contains header like these. +

+

+
Request URI: /foobar/test
+Request Method: GET
+Request Headers:
+Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
+Accept-Encoding: gzip, deflate, br, zstd
+(more fileds)
+X-Forwarded-For: 127.0.0.1
+X-Forwarded-Proto: https
+X-Real-Ip: 127.0.0.1
+X-Zoraxy-Requestid: d00619b8-f39e-4c04-acd8-c3a6f55b1566
+
+

+

+ You can extract the + + X-Zoraxy-Requestid + + value from the request header and do your matching for implementing your function if needed. +

+

+
+

+ 4. Render Debug UI +

+

+

+ This UI is used help validate the management Web UI is correctly shown in Zoraxy webmin interface. You should implement the required management interface for your plugin here. +

+

+
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")
+}
+
+
+

+ 5. Full Code +

+

+

+ Here is the complete code for the dynamic capture example: +

+

+
package main
+
+import (
+	"fmt"
+	"net/http"
+	"sort"
+	"strconv"
+	"strings"
+
+	plugin "example.com/zoraxy/dynamic-capture-example/mod/zoraxy_plugin"
+)
+
+const (
+	PLUGIN_ID              = "org.aroz.zoraxy.dynamic-capture-example"
+	UI_PATH                = "/debug"
+	STATIC_CAPTURE_INGRESS = "/s_capture"
+)
+
+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.dynamic-capture-example",
+		Name:          "Dynamic Capture Example",
+		Author:        "aroz.org",
+		AuthorContact: "https://aroz.org",
+		Description:   "This is an example plugin for Zoraxy that demonstrates how to use dynamic captures.",
+		URL:           "https://zoraxy.aroz.org",
+		Type:          plugin.PluginType_Router,
+		VersionMajor:  1,
+		VersionMinor:  0,
+		VersionPatch:  0,
+
+		DynamicCaptureSniff:   "/d_sniff",
+		DynamicCaptureIngress: "/d_capture",
+
+		UIPath: UI_PATH,
+
+		/*
+			SubscriptionPath: "/subept",
+			SubscriptionsEvents: []plugin.SubscriptionEvent{
+		*/
+	})
+	if err != nil {
+		//Terminate or enter standalone mode here
+		panic(err)
+	}
+
+	// Setup the path router
+	pathRouter := plugin.NewPathRouter()
+	pathRouter.SetDebugPrintMode(true)
+
+	/*
+		Dynamic Captures
+	*/
+	pathRouter.RegisterDynamicSniffHandler("/d_sniff", http.DefaultServeMux, func(dsfr *plugin.DynamicSniffForwardRequest) plugin.SniffResult {
+		//In this example, we want to capture all URI
+		//that start with /foobar and forward it to the dynamic capture handler
+		if strings.HasPrefix(dsfr.RequestURI, "/foobar") {
+			reqUUID := dsfr.GetRequestUUID()
+			fmt.Println("Accepting request with UUID: " + reqUUID)
+
+			// Print all the values of the request
+			fmt.Println("Method:", dsfr.Method)
+			fmt.Println("Hostname:", dsfr.Hostname)
+			fmt.Println("URL:", dsfr.URL)
+			fmt.Println("Header:")
+			for key, values := range dsfr.Header {
+				for _, value := range values {
+					fmt.Printf("  %s: %s\n", key, value)
+				}
+			}
+			fmt.Println("RemoteAddr:", dsfr.RemoteAddr)
+			fmt.Println("Host:", dsfr.Host)
+			fmt.Println("RequestURI:", dsfr.RequestURI)
+			fmt.Println("Proto:", dsfr.Proto)
+			fmt.Println("ProtoMajor:", dsfr.ProtoMajor)
+			fmt.Println("ProtoMinor:", dsfr.ProtoMinor)
+
+			// We want to handle this request, reply with aSniffResultAccept
+			return plugin.SniffResultAccpet
+		}
+
+		// If the request URI does not match, we skip this request
+		fmt.Println("Skipping request with UUID: " + dsfr.GetRequestUUID())
+		return plugin.SniffResultSkip
+	})
+	pathRouter.RegisterDynamicCaptureHandle("/d_capture", http.DefaultServeMux, func(w http.ResponseWriter, r *http.Request) {
+		// This is the dynamic capture handler where it actually captures and handle the request
+		w.WriteHeader(http.StatusOK)
+		w.Write([]byte("Welcome to the dynamic capture handler!"))
+
+		// Print all the request info to the response writer
+		w.Write([]byte("\n\nRequest Info:\n"))
+		w.Write([]byte("Request URI: " + r.RequestURI + "\n"))
+		w.Write([]byte("Request Method: " + r.Method + "\n"))
+		w.Write([]byte("Request Headers:\n"))
+		headers := make([]string, 0, len(r.Header))
+		for key := range r.Header {
+			headers = append(headers, key)
+		}
+		sort.Strings(headers)
+		for _, key := range headers {
+			for _, value := range r.Header[key] {
+				w.Write([]byte(fmt.Sprintf("%s: %s\n", key, value)))
+			}
+		}
+	})
+
+	http.HandleFunc(UI_PATH+"/", RenderDebugUI)
+	fmt.Println("Dynamic capture example started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port))
+	http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil)
+}
+
+// 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")
+}
+
+
+
+

+ 6. Expected Output +

+

+ To enable the plugin, add the plugin to one of the tags and assign the tag to your HTTP Proxy Rule. Here is an example of assigning the plugin to the “debug” tag and assigning it to a localhost loopback HTTP proxy rule. +

+

+

+ When the plugin is running, requests matching the sniff conditions will be intercepted and processed by the dynamic capture handler. +

+

+

+

+ If everything is correctly setup, you should see the following page when requesting any URL with prefix + + (your_HTTP_proxy_rule_hostname)/foobar + +

+

+

+

+ image-20250530205430254 +
+

+

+

+ Example terminal output for requesting + + /foobar/* + + : +

+

+
[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Request captured by dynamic sniff path: /d_sniff/
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Header:
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Method: GET
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Hostname: a.localhost
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] URL: /foobar/test
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Accepting request with UUID: 8c916c58-0d6a-4d11-a2f0-f29d3d984509
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Fetch-Dest: document
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Accept-Encoding: gzip, deflate, br, zstd
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Accept-Language: zh-TW,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Cache-Control: max-age=0
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Fetch-User: ?1
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Upgrade-Insecure-Requests: 1
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Priority: u=0, i
+[2025-05-30 20:44:26.143149] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Ch-Ua-Mobile: ?0
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Ch-Ua: "Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99"
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Ch-Ua-Platform: "Windows"
+[2025-05-30 20:44:26.142645] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Fetch-Site: none
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964]   Sec-Fetch-Mode: navigate
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] RemoteAddr: [::1]:54522
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Host: a.localhost
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] RequestURI: /foobar/test
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] Proto: HTTP/2.0
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] ProtoMajor: 2
+[2025-05-30 20:44:26.143165] [plugin-manager] [system:info] [Dynamic Capture Example:22964] ProtoMinor: 0
+
+
+

+

+ Now you know how to develop a plugin in Zoraxy that handles special routings! +

+

+
+
+
+
+
+
+
+
+
+
+
+
+ Zoraxy © tobychui + + 2025 + +
+
+
+ + + + \ No newline at end of file diff --git a/docs/plugins/html/3. Basic Examples/img/4. Dynamic Capture Example/image-20250530205430254.png b/docs/plugins/html/3. Basic Examples/img/4. Dynamic Capture Example/image-20250530205430254.png new file mode 100644 index 0000000000000000000000000000000000000000..3c264f2685686edf42986745582bbf092f4e8bc3 GIT binary patch literal 48039 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 Z0fV?nxvhzJ>raARNnTAZ@7Zhb{{fgQRb2o8 literal 0 HcmV?d00001 diff --git a/docs/plugins/html/index.html b/docs/plugins/html/index.html index 8dc7832..621e20f 100644 --- a/docs/plugins/html/index.html +++ b/docs/plugins/html/index.html @@ -123,6 +123,9 @@ Plugin UI + + Compile a Plugin + Basic Examples @@ -138,6 +141,9 @@ Static Capture Example + + Dynamic Capture Example + index diff --git a/docs/plugins/index.json b/docs/plugins/index.json index ea09214..852c6d6 100644 --- a/docs/plugins/index.json +++ b/docs/plugins/index.json @@ -64,6 +64,11 @@ "filename": "2. Architecture/5. Plugin UI.md", "title": "Plugin UI", "type": "file" + }, + { + "filename": "2. Architecture/6. Compile a Plugin.md", + "title": "Compile a Plugin", + "type": "file" } ] }, @@ -86,6 +91,11 @@ "filename": "3. Basic Examples/3. Static Capture Example.md", "title": "Static Capture Example", "type": "file" + }, + { + "filename": "3. Basic Examples/4. Dynamic Capture Example.md", + "title": "Dynamic Capture Example", + "type": "file" } ] }, diff --git a/example/plugins/dynamic-capture-example/go.mod b/example/plugins/dynamic-capture-example/go.mod new file mode 100644 index 0000000..fa3d2d9 --- /dev/null +++ b/example/plugins/dynamic-capture-example/go.mod @@ -0,0 +1,3 @@ +module example.com/zoraxy/dynamic-capture-example + +go 1.23.6 diff --git a/example/plugins/dynamic-capture-example/main.go b/example/plugins/dynamic-capture-example/main.go new file mode 100644 index 0000000..3f9441d --- /dev/null +++ b/example/plugins/dynamic-capture-example/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + plugin "example.com/zoraxy/dynamic-capture-example/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.dynamic-capture-example" + UI_PATH = "/debug" + STATIC_CAPTURE_INGRESS = "/s_capture" +) + +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.dynamic-capture-example", + Name: "Dynamic Capture Example", + Author: "aroz.org", + AuthorContact: "https://aroz.org", + Description: "This is an example plugin for Zoraxy that demonstrates how to use dynamic captures.", + URL: "https://zoraxy.aroz.org", + Type: plugin.PluginType_Router, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + DynamicCaptureSniff: "/d_sniff", + DynamicCaptureIngress: "/d_capture", + + UIPath: UI_PATH, + + /* + SubscriptionPath: "/subept", + SubscriptionsEvents: []plugin.SubscriptionEvent{ + */ + }) + if err != nil { + //Terminate or enter standalone mode here + panic(err) + } + + // Setup the path router + pathRouter := plugin.NewPathRouter() + pathRouter.SetDebugPrintMode(true) + + /* + Dynamic Captures + */ + pathRouter.RegisterDynamicSniffHandler("/d_sniff", http.DefaultServeMux, func(dsfr *plugin.DynamicSniffForwardRequest) plugin.SniffResult { + //In this example, we want to capture all URI + //that start with /foobar and forward it to the dynamic capture handler + if strings.HasPrefix(dsfr.RequestURI, "/foobar") { + reqUUID := dsfr.GetRequestUUID() + fmt.Println("Accepting request with UUID: " + reqUUID) + + // Print all the values of the request + fmt.Println("Method:", dsfr.Method) + fmt.Println("Hostname:", dsfr.Hostname) + fmt.Println("URL:", dsfr.URL) + fmt.Println("Header:") + for key, values := range dsfr.Header { + for _, value := range values { + fmt.Printf(" %s: %s\n", key, value) + } + } + fmt.Println("RemoteAddr:", dsfr.RemoteAddr) + fmt.Println("Host:", dsfr.Host) + fmt.Println("RequestURI:", dsfr.RequestURI) + fmt.Println("Proto:", dsfr.Proto) + fmt.Println("ProtoMajor:", dsfr.ProtoMajor) + fmt.Println("ProtoMinor:", dsfr.ProtoMinor) + + // We want to handle this request, reply with aSniffResultAccept + return plugin.SniffResultAccpet + } + + // If the request URI does not match, we skip this request + fmt.Println("Skipping request with UUID: " + dsfr.GetRequestUUID()) + return plugin.SniffResultSkip + }) + pathRouter.RegisterDynamicCaptureHandle("/d_capture", http.DefaultServeMux, func(w http.ResponseWriter, r *http.Request) { + // This is the dynamic capture handler where it actually captures and handle the request + w.WriteHeader(http.StatusOK) + w.Write([]byte("Welcome to the dynamic capture handler!")) + + // Print all the request info to the response writer + w.Write([]byte("\n\nRequest Info:\n")) + w.Write([]byte("Request URI: " + r.RequestURI + "\n")) + w.Write([]byte("Request Method: " + r.Method + "\n")) + w.Write([]byte("Request Headers:\n")) + headers := make([]string, 0, len(r.Header)) + for key := range r.Header { + headers = append(headers, key) + } + sort.Strings(headers) + for _, key := range headers { + for _, value := range r.Header[key] { + w.Write([]byte(fmt.Sprintf("%s: %s\n", key, value))) + } + } + }) + + http.HandleFunc(UI_PATH+"/", RenderDebugUI) + fmt.Println("Dynamic capture example started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port)) + http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) +} + +// 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/ztnc/mod/zoraxy_plugin/README.txt b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/README.txt similarity index 100% rename from example/plugins/ztnc/mod/zoraxy_plugin/README.txt rename to example/plugins/dynamic-capture-example/mod/zoraxy_plugin/README.txt diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/dev_webserver.go b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/dev_webserver.go similarity index 100% rename from example/plugins/ztnc/mod/zoraxy_plugin/dev_webserver.go rename to example/plugins/dynamic-capture-example/mod/zoraxy_plugin/dev_webserver.go diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/dynamic_router.go b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/dynamic_router.go similarity index 100% rename from example/plugins/ztnc/mod/zoraxy_plugin/dynamic_router.go rename to example/plugins/dynamic-capture-example/mod/zoraxy_plugin/dynamic_router.go diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/embed_webserver.go similarity index 88% rename from example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go rename to example/plugins/dynamic-capture-example/mod/zoraxy_plugin/embed_webserver.go index b64318f..b68b417 100644 --- a/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go +++ b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/embed_webserver.go @@ -145,6 +145,24 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser }) } +// HandleFunc registers a handler function for the given pattern +// The pattern should start with the handler prefix, e.g. /ui/hello +// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix +func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) { + // If mux is nil, use the default ServeMux + if mux == nil { + mux = http.DefaultServeMux + } + + // Make sure the pattern starts with the handler prefix + if !strings.HasPrefix(pattern, p.HandlerPrefix) { + pattern = p.HandlerPrefix + pattern + } + + // Register the handler with the http.ServeMux + mux.HandleFunc(pattern, handler) +} + // Attach the embed UI handler to the target http.ServeMux func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { if mux == nil { diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/static_router.go b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/static_router.go similarity index 100% rename from example/plugins/ztnc/mod/zoraxy_plugin/static_router.go rename to example/plugins/dynamic-capture-example/mod/zoraxy_plugin/static_router.go diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/dynamic-capture-example/mod/zoraxy_plugin/zoraxy_plugin.go similarity index 100% rename from example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go rename to example/plugins/dynamic-capture-example/mod/zoraxy_plugin/zoraxy_plugin.go diff --git a/example/plugins/ztnc/README.md b/example/plugins/ztnc/README.md deleted file mode 100644 index a942efd..0000000 --- a/example/plugins/ztnc/README.md +++ /dev/null @@ -1,11 +0,0 @@ -## 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 deleted file mode 100644 index aa0cc97..0000000 --- a/example/plugins/ztnc/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 875979f..0000000 --- a/example/plugins/ztnc/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index e19e043a8d2695583a0216ced15162e20008bc64..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7839 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 UU0+Qx0DUfNU%ObSY5VBE0NQu90{{R3 diff --git a/example/plugins/ztnc/icon.psd b/example/plugins/ztnc/icon.psd deleted file mode 100644 index e8c221b8fa05ef43254524d65d6632579454e001..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 127851 zcmeEv2S60Z_x~JVSv$5EWAsFgB?3ps9tBZijo3>J!hr}JrPnkJgqjj=~; z@r;$&dql`POtFsFKJsn-#+j@Amb@fsj{~S720@Yp{9MZO*ub;6xa2*^*>-EuXotw>gl(fY6`jgpodI4UYe-@(Cw zR%H4!_A4^lRFOvK#QNwI6cy<_E->2J#YyFCDj6Bu1CzEsT6Lg4O4~mwDx#y<^S+~_ z^ijIeQPE1D08eF$L1CKUs90Ug_Rht{&Gnm0Qu_w#)g4_`t{#pmS4UUx{w^ME-QC){ zsNPn2w^gYu3K1oXmC*}h5ge!wEK4a?vc=1Z3JwbyU#3#blEyOHJ5!J?KxZRL@hTSo zM}rY90a@~pn37){7*kT){_1i1o`K`lTAkDial~0$x&($=$4ZTi2xD!F4%F(@{l`bE zJ2>=H>!M<`L2AqK7fYgf%=NLldIv_TJ5JDRg4#Qmlw!uZ)Dmd3OD=(>6fZ%9JR;1x zH)5=HCoNl4A~qI%@rjCv()Nl9R(Eu>%w~=@H^U;AzmHE}ZB$5Dgu0`yU$-tw|E@ls zE^RzL9bKJVEQ_&_`>PcvILM?i(J|TxW-Ee&oYfKPNVP_Yw=&7yyGt6?5`^)fZQMC%h7>0FZ4(&@X7g+VhK$P#nv zs2V#frXyR5cX#V?S-tRmmfP?t$Ep!Bo@V_N-sng6{3#~)T%p&LJ$6Er4&oF&QiSSm)Sh) zagj8%*f^HFPKmK_J>c~?o7|okM%gq5eN5yQ5Hgmt1Kv)WMsHZP?Uv`;VKJCCK(wn6BK13 zWVp(Ll1WB}%LGMP2pO)jpk$Jf;W9x{7D9%rEGU^|WVlRFl!cJtDho;`85u4U6lEb~ zxXOZ(Nk)dt1Vvd08LqORWRj8LGC@%mLWZj>D4AqrxJ*!#g^=MY3rZ##87>nPWg%p^ z%7T(fMuy7-MOg?LuCkzHl9AyuK~WY$hN~N5yQ5NE*a8)wDNT}A}?ZH^QCdl41Y|CCeEI>%#Lloax ztQ@Tk)9Cxe=%Zuwh%84%`s(xnqwvxklknH*)oM*lBu$_+yGDj8ajnks284~H5?#Xd zG!IP{%T#OWyH@?X4(^YrTEvG05)FyMM@hO7SE3>=_$Y~vsEL+@A=J^l#zOI$QdCHQ zS|1bLXVjP=M3)=gczD$>9fvJsLY1OT>EKyAyF}tr#eI|rdYN8@iJyf)dmDbi1B^3LGKKqC9uC5LE5P3 zL3rz>FV)*HO{fH4HJZ>5ZR!%G*GEN0L}@~eS*l1WvATa$KPI7KjGY31jHj)q?WfbCB8w5_ z>w`y}%{$_UzJ#qL_)`)wUqi|eItt>bPxJ*UONLYUN;@#S@RT~e(Z z7}1kXM1@s7W zaJ~><8N)uhxHy}4Eoeog3O+WS9x_^-S=Cu|cO-T18K#M{f%->cZKH1-4<0g9S>Y7a zpTn$!q6Y@)qI(7SQ5S*WZDb{VEmsuq8+XKsYBFSHQoeQ4uqD$1;TJ^g2UK%XoP93 zUh20f;ZJ6>D1nlqUW2H$vWV0k=|pOru1#zoBoMn=m5J@}ACaOkjayG{AfYpFP@@!4 z??EG-g;s&hEU-4Zuuvt9_UYGO85E-(OKIauSdLU8)k$sgI%!CnkQT%d8wfAbj=V#< zl6OfT(w__=?~@=hnnYqV5lcQIQ_1J#EAlP*fh;6T$x5=8Y$OJD9D6S}ODbf@~7gTv@0rCwOoU8Zbjwp;G@!V=7IpG{4fuN{1^YReD^xa%G3gZ7RQ4Ijr)>mFHLf zrSh@Lw<I^2^0;c%s&7?&r|QtE`l>UluC02o>h-G6 zs@1CIRL#FyaJ4Db7FF9;?QFHI>Rk0^)jL;zzxssgbE|Kue!6;Q4X(ypHGFCW)|gyl zQH`B7F4f4juVwFI-^V`E{%iX<`{VYhH7nG7t7g}l!8NDVTv792%{#ShYc;9mQ!A*} zr?pnrI$Y~s?eevo*Y>YHruJ90H`G2=`(d4$b=>O=tTV38qB^_l+^lO`_pQ4AbtCG| zs%xlwv2OlrZ@$*~HT7#>yteVRv#&j^_eQ;U>Z$8}S#MLl3-$6}SH9l$_3+ofdwu)s z$@T5(x2peM{n+|H)<0Z7^NreXw0k4yjW6FYym7UGO@meq`Zbu?U`2z32DxuGe6!n| z(Qhtz^WdAA4eK_1r{S1}-#6UTFs)IoMjac4HTu5M-bU%lI?B$<2<3d`A?3ry4H|cE z9MgDN<5P_b9GW`}aQMVwlS6WoN=>|*gf#iS$$=&hn>K3NtLaBg*EPM`jBDoIY;?1E z&5kyE`d0I|2E8@?t?h56Hm}#*zxjmb>zXIEsM?}aiw|2YZ*jh5`Ig=-!&@$Cnb^vv zl}D@5troR9{kF~9o^OY}z2xmPt;@A;(^}JddFxA#RUA7zj&)q;c+07-Q%|Q)oVGh< zIX874>inJaQRgC+hbls~QgzLxrpvo7pSbLD$#HGv8sxgj^}JhEH$S&2Zadr_xxekM zcK^}+vPUhCULMmu4tVgMUY=Udjh<;odt`pRcWNH{UOPPj#)?bwJmJU9bB& z_=Wnd^LyCMqua!8`?{Cw-lO|>-7opS=^y03*8kzVUhhtN_fU^2Jpy_x>~XtitDf4P zyL#F5dbih{UP-;1_15&>-iP$@?=z=Qa^L2CKkOU-UitU>zPIST`~6({ebn!0K&^n0 z0dWC&{k!y^)&JUn76W1i92i)A;P8QK2R4}bXZp|*#1 zwN9ZMq1&Oa4QDDLrbWzWF}KEc9J^@jv)F;LTgKUs(~L_P-)j69<5MR1O;|P2Zeq~H z10Oa1XzE9|CUu^)Y_ekVsL2PWG?_AO%Ds=fef;y(N>jt9p8Q1h$?Q*_d^+gUUDMv2 z_VKhkpLP3e-RIRl*M5F!db{aMXV}dMn{nz3w=WiaQS@c-m%o3d`fBc1gDwj)GW_gKYm;OTg|r@zw7kf&$I1kPndmYPOmxJzE^(#<@Zm182Q8Tx$bk9 z&Z{&pX5P*DJ?HOO&~(AK3knyGUU*?qmqnWvH(30|;-^c3mz@3aogX(YeRJtoOY?sU z`|0wsZp*eUe{1>N73EiqU2%Wqz?DZ=d9PZ#y8h~~R`Y8#Yi|DB@8?5nz1FUcYY;bU zoz1$Kb!qE|uTR|IyJ5#h$BoN2)!p>fFXWe)U(z>!u=%2)hvC2$?=2gDZT9Qpt+lp( zxlOTc{I-YNM{mEmW5|v(JKx=TU{||cTjQPL*ZkJ_w?(__?4Gr!(w=F1guN5?=I)E$ zm$^T5|D6LP4_rGq_~7|N?;SdIxX0mRNBoW)Jlgr_-eVn({dT!JC=TBVdec{Z-0T(Y{8h+{K z<>1SyS0b)Fx;pl1!L^T*$|Zf7TqAkz^#<3M-)MPb(@oEtyKnj4O1K?x``VqLI~jL% zcMI=LyI=MG+>}NsYg1iQe@pXAOH3b@elH_BqagG12lfvZXSK-sHT#|HgolG4-g~5d zB;#)_7%~ApKKKl#KvA>Yp)QJvH!0m98z>bE6dRv z9}3B7vLy-|yK?0#ROBjERuoUf9VuHWu?A7tDQs=*Y|2$AU$LBR6*nZ>+uAj*=~Axq zdx5naKAh@WzV>&^Hg{>#v`)YMqukuJpPccj&}??V!&{GnbRKn=8$Nx@cTWG{t_N1= zJzqOJ;I=wv%d~^%V(vWlYySPp&wl;k(D}PhR&70e;a={*kg=c7UA^td#rt_)-3Eom zPM^1C`_W4&Pwk0~4O(l*+Ek%@ISg8lu@JalV1>X!DC z***bvf^_bWnw6&wsPLA@fwO4Dir0d>4)D~gZ=0G>dJCGHn;={zRcu+E_M{U@pY7l5 zRQwqKrnCFa_HX+AO0X@P&h~$|q=*0^vAFo0HYbCIKi%Q+b-B|MXH*UM+c+^PJACkk zqcNu+&HCcY$Ft64Xd=I9+R5SMfd!3HhaNZ)^>p%-)O7;smcG{I_K!Y&Uwb}jovZqD z7j38Bn}QW@kvTVO>fH~{2)obO=4@)RWdk3bk+=K%VLyE9J*i%#F1)I~VC4HP!p92a zXv+ab&PBiOj^A_W>i$p4&Ff!n@A2Hqi}+QcS?|qyykME_HG%lu3gmk#N;HKzLd@jkZY zwz(;?!&@90JufMZeo|7g;J5z$|N^(6X0*uwea z`B4{_UK}Bi3L{fH-MO&$=NkjAoPBuZyx~ewzuzN%^$zdayW!QV)z9p?Kc!=K5?>)d zDq!tEx5o}muI^8prgwegMEd^eig_8X`72|{=D4_@*Z$h~$hjUJzZvkW%cuEUw}jW8 z?cXnQ>ado6>1&UzJoDW!=T$e}T{pDh!ABczCG_)eWm8vlTj*@R(J5f9v)*Mj)43Um2Q{9rvb3a%@JE zh?5O>511DcGB5gZyM>pE!goi^+cI&=nf$M|3Z&kk{E-7T22aiVYHeJVjI7+7Q?6C^ zDjXE_`OVx08$zzFiwo+$VdEAbfs7m!-6-mZ6W@Mxw!Q81DbvoM%(&m!Hs|R1_b<=( zZ{9DaS<~757n#k#ROKga8lbHo&B<_jbLpTxzwSwR=k0LE5KRqI{_}Yg5`qSI+J85(nvQQ9 zw@o1D+l-kMb7<_K3m@J8MKj4!<1;0rcX(!}yrXeDH$58p;4vp@2-X? zk2q7fd+tSnr1#%=@WSz))f0aZ$eA%c&YkovdN@g84G*kj`>1D$6D#;1SU+|56(?Pv z=r@K`zg9SJ!q?X~O!?}|q|@u#Ee;+sN4L-;vFm^%dw(CbUm&kP->_xvty}J&=Dpi% zZnw~+&@h3lKNld7ZSjrb<}VG--+C@AYjoXRA8DM9qw2Vh{bq`j^P075s}J&M zQm}kUe={KG&4BbwgQKF#ma#x+uJE{L{TL5Ar>|o}L*r zkMEtIRP@D%X?(X2)3$jhd@^!YV8@K1r&p%$&Fa-CH+`>ve!Jam<6G@oJ)+08$bnTa zTt3j?l6#S^;f?S)ZN9E~xK-Ojg@&y7@`1BEU2LQZp6%aa$oE*6Rw+Y6?&u5KZ80Qo zZ}jG>Jp1E?7dP*HIQO@0aRQn9Ma=m-=R&$>NA~ZRoOktc)$nd9-kVhUH*({y@BKcZ z;g5|9I&Pmj;Da}xUhRD`DSdkU^Lpn6l6sA8awc}oldLO6Z}81mjreMOyG04jUFv-_|7ewgIhPY0)Xobh{D3*N zB~Y{i7c54QuDy>N4c)Wy%*ElEna#Z#t$lN3{JR@xJ=vSONg&e;1@iDl+PpgFCY-+5 zC~nBc!xI|~v)w*o>bKQbXP?Xs=TDx=JbtuDS5$Xv#+RG=rcG)W*RMr-z0Bhqw*1=u z&67)4r42o~?LvG})%1%E!$0qMeQ?2iSgdZxH_V&6<>=QB`#c<9w4lgkSHqFpJvOCX z>~8zfwINHNjTzkVbZqw7?Qdr!SGrs$w{^GIe5=3zOHbZ@YgYI?kMo@l&+4=FFyGE8 zVMx>bf!i;9P$6~mT=z@5p*NoO84`4*sP%-@3k&Lv-FGMEn@-gywF}_)^qX+B@lk$= z$6$$7t;r?cRX`4cYaFs{AA6frB4RFhAB~xuU_XTeO{-vgU5fd zeDkJ>&YRBb_O9)gcd1jKXPYx-Mtj~J)$iPsWv3^8{LFR3MuBXvd%6!aafIhrU6=Sc zC0zWbSL>+d2k#cxX2wV5c4)eLPw)CC8XxBe=N65;F{qK7qn%y*_Xo9$u?ajWuI=X+m&&R;2-^L_3$zF+>LKEAeIt+y5Ze5<2AFM z%+uV|XI5L}zhcduOJ4sgfrzW%B%Su)+3cZ%zVQ|lrCqL|V?P#N@MuCreoE+;k zY}%u{?RAA%^;&I!3i!7aHQ-_~4OltXdFX-sD|6-^)%RY}yRO&j`swRG(0#LL>CQX3 z6ZwsSn;u`R-Z_2BFx})W4Hp-!PD*G{VS4C|0X^%q`KHs#qZ<$Q^3?1)c{z9Y-iPxp z<+nP2W}muNWA6#wYEO?lT6nQ#sQtHJOxx=2ncHFD$iasWr4JtAdM5B#`Ww4zCRQ$g zYSN(Bww^3H*m}^s#kpfT{@Q5c;CkC~EA^hZaOBi)>)EynN}hbN!;c?dhhf@VSZ7_F zZkIrM6Ig%gK zDf7ve=X=i>_U1o*)^18b+w6eTTRN?d(KcFgZfW^_^*P0<)fYRRt-9g*59{-;6xEs# zv2(m{_eZ|-KYH{6(5eFSp9}yZKKetJS%D@t5)*TfM?ZV77QH-81!z zBIk~qMyUhl^)T!nKJoR)iR%O3n13rMwz^Mh!GIrIzn}Q=f-j!0N!!!r`J<0Ehd;dc zv}c0du5K4{4zIe@5DV&&Ze4Xr^)3Yr>-?78JoUgc{F?Ytsgt+Z+*r4}uYHBgZKEew z>M$|=Mvsi#i%EIAH^ohAw>CO_--q+ulCIUuPj2+%>GT#i`5|eSJ2Yy(qF+JsH}l`u zOuT+B`0~YzIY%p9JyH-J^~}9mcl#MvY94*hW#cEqhY$C!F~3bY+t;s@FRXu~@7_l( zKjXvqKF?ewr&8>b9D{vdLVKtcuuoZS`T<-c#;n%nahwsdOkStDg)QsmeakFDF& z+C8n&HugecwI%1bOqknlLz_3>*?DRD&Q%}pEQ;U#mdDq+Ndo!p=}PU9$JzNe<`qtE zHnHFOPUoNRIl3T!)uc&Y8L>mt7OXjU?Dt<5HVU*IA31C5*I|6-!l+!VDBa^jhYZuy z%bM8r-s=7SO~!xFc;l9H`4j7>Olr2^QpffB$-C-jrYAXla5dq}5r%zNmdu>HC;oLT z@FTYr*00_*f6NZof;>y$LSU}@N9Q3GBR$juG$S<%@gg%cL@I7?WZbZ z_J2RAlULTd23nu9L=}H~&-DG*{G0wTHpXlqUf6HFe0z3<-2-ThT|6NJ=w6=gkE@f; zBp4@*M&T@x5)U3mgVy7tV^lOGYW;zP5~q{`h$k&je3Iz^k|>;d^&+i6R3sG?HQ50i zY`qa*pB79&kr-!je{50=Md=g2e!jB2T znGfUjrC(Q0=y#B}Rk^lx^;SyXL5_!LKPk*@`^iQs-v)OU%rxqvf8`Z;VMKk2#gBiL^Toi=rFf?l0P&tH%sLjsEXz*p&0v?|OJe4s+3wk=IEFZfK{=e=* zCfd4)7!7R|ZL!VA2_aD|PNUO;w;L6z31*y5qyn=>)~-*i&xs?)aDyt4M#$fPR5KmBfdO zpx+(eKP*yBPr`M+@^GlnnNLozc7%-6MjP|uQqmvbq>$QC+<-3rN<*49RzX`r|5}s< zWhzsC=jq=@E8Q%Srgtpu2Gt!NOq9fpp=oso8hY)l(RY^8I%`;3 zwLqM3#Mz)gUBKu-rqLWbg`^2qkFz+dRjI26U$stub0Sb5Os7X9L#0aX+gIY8jR|2v z`br#)Kn%_aL}@`WmUE`%DO(f?rao0|iNuZ_schdsZD6Q2FnV;q7>zlH@!SLAXoZM3 z7G)tHFj}ofjpiiI*_bOpKR!aul9ehZfLu7XiL*{&YMmG%Hq6)t)5W=S@E8~d_8S4# zC3hYdrc(}6;s-#KDtzd5gt6$sLy$lt=|_xYJrn!KIinAqwc5b(%3(Bvv0`(#|B`NS z&hMh#VIjsJE|9v-Is1iab$T3+Qu|{#HG0}cvDut+fId*Ge=)kNCiun30D9)6iyA+X zptdT4Mt2U;tF@M~opDC5`}k;_p2ZnLu!;Ujvl2`x=4}T1LgPBc03(fdI~E_ha`)_LP%e};lbJ~uCq$2 zD$WCRYCo;`gCBtr%)$%`3)YX;Nos)KYJttxs)NmN;<&Dk{)QE^BS11RT3pYhwhz+c ze4ZxMH%yB+6sVx6JO+rDMY|_;j&uU~e4d}=8 zTRpxOo|SPez&lH9EJduG=_^pozk2a@L=U|5q`}RJC=yO$u>T3f$;DvmeR0mcqnHao zGpZCS8=;C&O;sS&7v2yW2?|vq4t>3@d{f|5b%YMW2ox@;rAlfO{14h9Wj$pBp`xm`&`ubps;H`@s-mit9%w+h16gyQwS&V*knk9df4w`PCb@|Qe1jxYlq(XkKFk(IP-t($`^Cw#m$K1x>>B1&XRdCyLiqe zdhpIA`0vgy@!m~~0LPTktb=7%DRUOdN0!uwd9cPVnfzB5qk{zpH!x;gmngiUY0)Pd z=dTaMYgm@CmOie@ZIw_mZ3dn3I#SpK_{o?`L4kBHV03()>5E6$tQL>PVjC1rU16i+ zQlfY4Z0#GfH9rDQO2Fu_5c3X~dFwub(K@Lt+F$A(P*Evv0QIm~ghh`7^x81|KI&+^ zn-)6SveP}oLId?NTKLxHF8i7zEsBupF?(W0(EID~Gk9iv*htYE1oTZVdfUOc`E~Az z4Ps1SsHvg7qq^WX;leFjW97Y?oE@vpfY~Fa{)(kn%-jvh1%aK=MulS|iC=r9Ht9br zjJAuqzZM{g<+Df&keDF7KVcy`#5^M6X4x5UiqQdLzOh+>Cz{pM-@D!XAmB7n^r%dX(a3^~Op`pZNr^x6O5U zp7SnN&CzhN9I=Y3@q8$ZuA!oV4`4q%E7rl3k{J=E56%Jj!P?Pkw1O#QiJpo1X_G%3 zxv?l&NBmH?M)F3C<+JXa%NN-)l-~;_YDg&QiR*xuwb;1nc(KKznTo;vHSt$=*;dZU z!`qEIBmw4lAT7Q=r51Fr~!`yo_$L^xKcpPg`QI!pxmycS|vw zB|N&5ePwAcAp6R)uUyK0K=zeiycI6{%Iv1M(Z{j8<1H=9FWm2zeP!d#V0dNB=aPM8 z(_Ks1S60iuviP_~_LcuaU%6vPcBI98*u@UN$4!s0l*5OkG~16F&j*+zkXn(ZO5gTD zY4(tb9*IGs^jDoG^u;zHnm&FQ%TCNlX}H!dmfQzhA8oNBDp8q!Q8;5DeX9#Q&Ol3% zjxbo1Xm!SjoqUl_7+9Ws!Gn+D=Zx4l`uyWhzU*&rb@JsMdIk}H``Uys~k_MDNsc-}`m(Peqgh?O5v`Z=TWwEllS+V5MV zjc-YzfBSGgi2W&WqD=lLBOoL2FGXOgApesQkP(m(kP(m(kP(m(kP(m(kP(m(kP(m( zkP-M_M*w$J|D|_6%|LYb`?w^c+jDU=QjDU=QjDU=QjDU=QjDU=QjDU=Q zjKEYuz5^)V1(Ff?HzOchK^XxV0T}@q0T}@q0T}@q0T}@q0T}@q0T}@qf&YC3 z=DR;~-(>{;_idcqLm2@X0T}@q0T}@q0T}@q0T}@q0T}@q0T}@q0r?J~d=E%Q;NOhE zR9^lkBOoInBOoInBOoInBOoInBOoInBOoInBOoL2zm9->2k_r~_ebu#jKEZb{7*(e zMnFbDMnFbDMnFbDMnFbDMnFbDMnFbDM&N%P0r?J~d=E%Q;NOhE)DiMO837pq837pq z837pq837pq837pq837pq837rA|8)d#2e69Oze=Q%1^+g2CvW3l3(_2)mZTN&BtH1K z6Bpvh;@?7S3-T8GyhWOUYKpKK!sd)?jw_9mN^m63#0~%25^uydA&rRx!lsaUn>0C2 zNbGZg_#Pzt$V=!r3z_&*=qz*mGgFBY^*bTHWuRj%WL}`pkgC41aq(G#kPyEh#DS2@ zq>69sjQIFuL5PoE5bK*qipVvgkPx?`Slc+nr=lHss2MGbeM)TH7G&iLiiD6PVq+f? z_gJvWTHyO!$`gOfEFAxwD174|Le}jjv2mD@l_e;WLkdb1Eg%Z}1!$8%LhgW^ktQhO z-S`(opP^lf0a*`(A_(3iijb`PXjD-Nay%h3G6Y^AZmB3Q?v7v+Z(o9#tuL{UPluc$ z**+bD3AY4A2;`Ck5(fxO-FYV1_}V9=35BRGgDBjxZV5IE2n3S_MV7s_q#`aM6@rL= zAeKlHXbY1CGN8bs$PB?Y8R$M03ecApLJ8nxe>Zpt;h0Nn9QplaKMp z_XQRE1Wl&Hkxh|DiHZU7`LvAqEJ7AwIFe)SpJQlZv(VN7JV}LU9&JnxL2h5lVn|}` zpAr&MAmj_g{vja)9@D5?G-3vjjV&Z;Ad{*5BU7#%I*?D0tB~dz@Q6x45whGK5gXsE z_yXi1GmvG*LsJ%%8i#sG98a^vKEgDiIUKT)*P(!nMMJ@z#GpL+cYkJA0(V7O_Ccu=wjnJ@7GON(y-q%g4dvB^&a+pM^dVr{z5Jd_-?Di#>; zC$_$^@d>>bG?w|8-Dz}f5KQCe+kJ5jGR>-6DUouK` z=lsY2s*%6{UzPuZI;37$-#w^ODgxGWQtrR|N^AYQ>k!NT=@k5l(~Ji_}8w&W;F);P#Qz@UQM5JOl0k zt~um7YCH$-5Uvm47hDi7fIEU~D!E853YWkgMN99I^9V14J4P-GcgPuXMYsa)cU*6h z(+IDDJAvL^CkeQTk_0XRy}kyCBq15xDO|6htYqN^xYNMq0y#{sBX>4Q1b3bsM0g9_ zS;U@0`>vy&Y;q2| zLQk@BtLHZG+JP_)+!b;MIBr9j4(=+sD;x&mci}r{lWVvhB3lqVPOx!ek1TI5ZLw+C+7?*=Ikt+ha zIY?X0<}2kMgImQo9#}sCx01{u*|-H}$rS?mT%@gFGv3U7M;_tU7EQ|(mXmKu4&(A5 zDRKoEl{};^BeTe3+?C43COA|0sTentJi#rSJfuAkmJ*4321${7j`4ejv>#a-<(?z$ zNAe9eu-4o&jOKGlE+JoIlS;XKNQ&H3jBP&B7PBQG>Llxec@p-=d(1*6`{=eWI8Dj zO5(C$ZOv&C_ZWIsgi^#b$_XfC9{HRU3f5c>v`&EJT$X0dJ%ZMm(#TqF00Q9Fs5Q@`T!uO{5zk<#dq*6rc6GDW)lA?VmUWe2# zsb<>8ve2brA|fZ1YK>!QT-ip^tP6B(%$%erRHMvVW7aP!Y38V2mc*5;vu5p<^x0b1 zi*u#VA91Eh^M{8{iE~n%h1MLM%jWrNIkUxCFU@mni(s}Ebe@ZLMY0`cD?=@bXn`b~ zV{M(xmW$dW(Z)%3%xv+fRTHhDWdE!!rP+#7+bG&w$)=jEFSWPUmRaPe4X1WmwCL2n zn>p(3nQgzsJ%RmOQNo%*R}kyfCUKat8jh*YFeUn7-y7uJ4=wT~kCFDcApxd$JI`Zg)do3ZwLtbHPDf63aH zx{3V1nY^wW@VEBByS~pnvg^2-{WELdEgAmZ0h3=R`h1(=$x)wA^al;#s6TiWKI0LS zuW0Q@UPjt6laF}`zUJ>_Cqn9fioWSCaMU-w0RQ!r$#1pxY0n`o5&rH?=KY?*^(=1S z-4y8ajznCkXMB_S$)XQ^2%a(Zp-~r9#cYhfBfHbuB659tHBIn^DUqwjw2nVra$b#2j^_{_07zmhL-_Lc`L?akkUgO%7vb;Wi}OlbH7@@sJYtE;}K)189kR9C2qu_dVin zhwg=T!$Z#|n^D_d_~Y5|y3YxRFuK|Bqc00b;ZKWutK;yVv*9scGw!!m0fz+m$=T4? z+rlY$#o5sKd%`*Rvf1!TQ-sU#K(pCwOon%uO{g8X4Zkg$c_AtAg|f+1crdxbcc5@@ zr%d}sE2;lvKWn`U|G(p2y_bJaj=!hJ<`MZ@TD@Gj$t~nk;RThIN);9wbNshc>pA## zroQ|WxT)MjjtHrO*p~lgE?bBbE}ME=8aIbCr~J3mm$-GNKFhQ?_WxEKvN@^e|2%4m zaE}vPB~$B!zXyYO?x?Zke^HSgrbQhnI}Z1RC73~?{C_{amdk~ADD~#mx#`?D z#uENpC_Mi%w)uaZy2!1^*wS8#l)PuO+716LJ1(mleV=IAercCL?+VDYyk`{eAjo^hf8!4IpSe>sx2o*>qgUNe{Tt-} z`}c{Kd)`;yCDQv1_XH{5f6Gp>K#*GczrIrx_weSOqM4HSj8;23dCyoP0%d)=XqNkL z+7-V1iKx71H1FZ8cXWS0SK2PodIxE_=lXA_<$dD6d!J}|ui&4zQ>0J*<)@4PSNm7< z?(CmQ$$Q5CWN$0)8Rb2r`1H=aJNsu+FFa?&dkNlM{Co8c)a&WHC6w+zPVY)c^Dw1z z8*gR%_DW}!(o5N~BfV12#TYltc%^Y|+9;iUqM{=DkB?USMQQYstV-De-;mRrL=inc z0VI%Uhz{Q^^)Bf_Mw4)SyHqctCcz|(#E{6Av}{+Uvnv8rC;k`f|KmD3qm(Ez3Z?m@ zjW>m4(KH>PP?K2F4-JcCVDtqj zy-0u7M2mt)&^pj+vG2GvW|%)Yl}#F&_NbSPcz9(l{gLDOPvPPB58=)qNA`=;9RJ^ z!qwGybu(VwOST`S>1hwlB5LTOm`+e#(2`*5%J7wV!&{Qp#0lS#*N$`|BgiDOTv=QB zy7En>s~G>Y(b5>Yge^m8Hs6<*5o(g(_ZEBq)S(0w+`vstNUlH-tt)2VsQN zUoyM}8Ac}J-QP9Jdddc>im0%Ps+y{Xs-~*8U@O=O6$PcxP8cTKlb}IEnr3<}yi5G8}6z4W#ii^s4MOi42dMnN@ZY-ph$JqrX zAoOIRmr>T+Sh`CasXkYgiE=Sfre?XC+U4qQO7$>No+ir6M0uMiQyp$-D!OPY-_2CM zo2h&^Q~7Ql)S+`j!)edmycoxN?uM?zT$ozy&Rjot)^&Hue{o_a-r3!q=5uFdxqHGH zb#?~`2w6$)ZIq4-SPzv%xX@G&Q_DQW@~D#<5EdC7q3#EP*QyfUCX8RTAU0KHt4?odUPLi2e+*VxrGa9$?G@G>cemzS}nUQ+wK z+OTP0?pW!-Qg9`JO~87adgaY}#{i8iI0&c_` zlbc3Lqedq`wGL2rqjeDN2h4*fN?%az9 z9BzuvD?ksu2lg0;HWH%$TW zpeqMkPFX;U67To8BbHhxahOcT5`<_H#g#yX+{Mf+wm^Nc{h@bx)bQ@<#)#6r*=AXd zlrR#+Zn5Za(;)z#@gxEg{jq5ZWsa7#p(tCq14uOf(Q<=Of(}$?loySg9f2>cMKUU7 ztEDG)DzSf}7HNlGwpw27i}}Wph#$Q*RT^&6uDxt^z1X8ysf+n((1o(q)dgc8!A4pC z(lKGXdZuB_KNAaQS}u~We6zshcu4m!E!zwAHs4I6TO1|SkKGU;xD#aR98IZeO7Sd~ z!kJQ3)+x&2Elo)t)=9*+v=kLFr4(<%22%=dshMUW^P-p~c7r1THwqhatpB)r7AdH%>mVb}6P+st{) zrohsyVUP0?tV^EQ*a#WxDZ9SWUDT^u1G>!;^OS@^DK=Nq1C_~6tB~7d`mj$E%T!g7 z%%utoojLub<6E)`!bb0v`$ku*;(eoNzRYZ611aWtl?6o_-&e3fbaVR3ZIj0LRoYg< z`2HaV()gBC4{0wgM5Lp|nBxB1EB6O?>PpADw0cZ>VAj_^>JRP$8rw!Ci~AKN>l^MA zmfkjH$#WL>6re-#JA7ICsE6)3;L|c}>782BJ_%(@zLn@<(OeU-&(UCq zLLEt|G;1l7tVzOrw*|^UZ@XY-(tFDEN=X+b^TJW~yZzp#bl@}m1 z-FEX(daxUEuds)9Qn`8HHiY#>a=gW+zR+5@9b(f^x2t&L#ndS=K?2A4kQ-8B480ZD z*T-}p&m8FoK-is5cGsBhYH2}KydUkh75jonPh`f1*W1&{Q{~cz>XZlWDgp^N58MuA zBT6^yW&z5X1)w9AH3YEyVn3j3%srQGuTm&1WxJJBx=WijP99z!xan&)l|Ba#NSzx)ZMMo=)B_u2{#ds)2uJyt(LV(PMfG(kw>r8XAWN zJz}F>YQO}5f(QsR4DtgM_S{H*fMUKoO;uB}8qOZ_0~FJ3dMjZ5uO6Vl`4XoqTPSD~ zs2QUd`AG(Vp;jDjD~03FJjpPgyYuU%3kO*RU#-l(=NYss-s!6N5B*oRZ_KDc{t5r+ zcQ@8xzY+0IXw9E#gY@l_eq zH%2{-@vRWs8oyskKdsm=nbO}grN3SJHsk28oVEhj8dq9|3vnY}_}7tiKn#_k|7d;} z{G#crxBlOhqih-=jots>t33bzYW&MK`ma78tlKHi|5r!w|0+k>G{Aa1|9^4v{Qs+w zFWcz9x-MF`Q=b17r}FYY8G*kOf$#W7+!Jml)3Zf<3YX5!XY>;O9(Rvh!sws)+uTiV zIipwc$=o$=4Wr}u%iI-iEu+`-7wNZGGI|q#jyun7Wb|hK40ndx%;;bFQ`~883!}I3 z30wlVjnO;##%F#a^C8~hFQ@D$?{`Ab|9pN#M{Ji@z-zYks7!SAFR z#rPCZ+xZ;`?=wCX)HZ%Q!W71*@%Oo3`K<_38J`Yn3;!#^G$WtF8Tc*GR*}yDwV5|i z?Pckipnl;uBg|m@15lgzUl3**`BZ47O#rTJy*755R zW*hl5E{=YY{$T{9{mS_@5Ex82NN=HNS>WH}X%ALisdk zDIM=8NLj_N2LFWdx!_mwtH9?P`3!CazY?(`p9hNaXzE(nX$tpZ5Yk59tDvC!pr?W$2c%BrKjj5Z;t2N%dz9@kbKjXe+ z?UBm2+~U($S$(&_vwX}(#`Pbrtmo0 zExsNXVr8~muf_G-ay_T(d-3(ZxPR~y+GpuMSo#y9eTjkP{mbJ1XmNkFxIdeYoRcRB{Qctlfm=x5Yq4LriJilK z{s2O8KOycn4uTi=8`rTjIc%{XDZXDxLi$ncXKotzH&;O&=YL0dg?VIGK+*lrP2+w@ z+%KKP{zu#|T?BQCKaKs7xSzU!oz)p$+=bD(dmcNpi~L1|;{NL_DD2j-ixc;2XTV?K zuVH_8hSisdlxutvcyWJs3j4vE{B`i+e()rJ9y_5s2*v$k0``yh_*CqC#r@>(*k7je z5BQVl1+D)We-xUVgI%w<|2%>{Y!P3GU9GrZwcN)Z;`f;Lw+Hxr+Qy35Wa<-1*Mll2kyhmr}RS1lnhurN>lHUT4_qt6@jjd zl>T4&x0WYy&pDDP&J>E(AM%rn$!q?AceGA_W>uRd7sr#tSLEZ0$(O=s^OY0hCTkoy zZkT4~_LPf!mBhHk8jT;vX*4tA62)nE9XB`%d6HBVXFC~bsc2^&TEt`bK;oYAHc2xb zb2-JZ?Mb{%&i2WVIUC2BiQ)|mV@}O2%PgAbydqKa5IK@=@-``p9kV!vA2L(TDLF#{ zugIB+MR)rh5R20|g(gWdIILwB zIv)e@fHfAfD4R?^%ycIKfPfqJz)Cg~oz8Qx2RAIkN>=1@d6I%Ozk{r?r+Gz+Bj9yRDMc2ICW|Fm zu?6WRmr83qBuXZV^=r~fmZWN#B1)cNCYm%Xj!(3Uo9xJ?hzpR^OS*c%6Y!hN$0;T=y%#gzqI%DAC()(cI#V`=@H$#4 zF2A=h3YV#6)ns7ctR?Bfc7s~C+pI~q;Mqek34(ssf-vMWZ$c27PTNie=?VbB6fAUb zb)*Jk_~LG(KPgh>m#~*4%sH_Zr0c%lJrkC+M09p0ZZFTY7Np)pTq=Bz6mg8HB$l=~ z$85CHS~QipOAoLbCW`AGm4$xf#Q8npY#k?WFV@)m%puF+Y=*_9hz{8@-Zn8#GmPi# z9I1boh0UTJa{v)DOcQq~MfCV62J$}{fno?4rpo_h1pY<@zB8~x3#^7k26kwH(Mt^M z&;p}>GO$Anj9z76hZYzeXJCgG7`@)W4lOWxlYt#tVDx4KJG8*)Uk&Wg0;9JX*r5eR z?=-MO3yj`vV22hMz1P4FEiihYfgM_4^nL?7w7}?t26kwH(T5G}&;p~68rY!)Mjtca z(1Ib)fI|z6KW@OG1w$snV~jrmD$S5$z@Y`kp9FQ+aNB@G3yePv>W1M4dU%TQi3S{6 zFeD>9&G<9G;i}=P0f!bCe-6|o!)1hL8Gqh@Lkorr1{_*o`~?FJEf~%rJkR(`pb`y< zK=vZzFB@=Z!EnleLko<*0{(;{0pVrFUqj08h7$;{GCm0@#|+02USoW+0f!b0M-e75 z{<;B&77T|GCNus9sDp+>2(L5#Ca433g9vXh{uai2pJ6{8b;jQ|;Lw6$FTz`lzhl6m z1;cKHw;6vIRJ`Fggm)N!57aJ0Ji@z-zYks7Vc1DEit#C+wi|XJywCVlP}>aK5vDLc z&45D-hOG!w8J`Yni{V#4Qjn%1HvrEKLoYTupVKyk;kD0!#V>FEinEOsI`VT zgbx{?1L|kPT7-`n{}|L7!_NqFj64o47}gkYXo2xhkV1JJS}^iBv|v~b{t4rAA-U49 z3Vg1S$DswoO2mqM9w^G=&;m<;3Vyj^1wxU325OmMIl`wb{h0xW77WV}iu`k=P#%XC zSo(9MEH(TD{yF3GK`k{bMVN2oacIHtBcw#W04bEmp#_#+fRrVMB?v{n5Y%GuIJCgh zdGL!2ixFD$IJCghi;%L=un3_gztDg~3&wOj#4#)|JTUSCBq@(W3oKm#HQz8F=^{_S z&wGKNhjf9-6Hs#vW*(2Az~Sx}OU2_U6ZeUM9a=EVMGl&3=J6A! zxq%&8Fw8cvLkotIcsw*TP>WCV({zc)p#;X`p(9JD{B#35v|#wwzz!{x$m1cWRl3CE zp(vBLiq1}efxHwJ_GviYghckz(@`|+drM}d(Rp0@wJ zv93>aK1tZGr=S1digo|D+7Gp-XuHR1KS}!=ZTI&0m)KX-UR6Je)&3Q${YULPdLDec zo}aDGi`Uolmvr8u=hL_Ad5@mgB&_~nD}SK=B-)1c#09O%G%ENgFwWni5yQt5A3Cyf7cu!|_?;+ezo__%=|qrWR5zWR6@kH?RQ%+Yx5;&(Kj zYkZH#|9F1zD)Wox57B(WEe6wP0Je&h2UpASJ=u=Tq`=A-!% zqy@v9^C?IRefbxp1@bdU3#Pw@v?r3U`Fsx2g0-*GuK6CM1;g=tFrF{^d=k=v=`WLc zYQ70+!EihujpwWJe0GtZ(R`O$g?xA;KVBd+{vh~}r^}zOBSrJ+c)op&%>3g>KJN1M zz2x66Uw8TZCt#P)&y)Fo8p-$L^}rnMpGE40St^ImgMB<*J>lw&{a{yb%utyejMO9X zdS#mSFR5oBE!cWpKsp>8;d$Qhc_giWAT1bn_0lBukE@rakd6l@sE=Gdb(YHNRN$&G zJ-cV9%%*}Vp055njYPFZ73b=;Q{eN#Me4g#HvUOkE(X(JSKl3{9-Iwkz^)#g3?MB8 z^E_R>c#Qh-YOp}%>*~oP)R&9FQUGbe#yb>1S_sytdR_hb1vTuG;1N~r7uFuFV-EyI zTCl(G3yid2vev_p7EIPU8PbBuT0cWtFnKR^FQf&NKMIVrVDkIS2hxJcTAxE&F!^2P z4QavTxA_`K3nuSjJs>TZ{3f+3qy>}TV1*$qn7kVsfV5!pE=~%h1(P*esF&7t*NC8z zQssY?e|v&di(AaLJ1OIMk!^7!reE6z=la{ZWR1Knih4n+{a4~vOn(c!5`4eiYSeOr zgO%FYcy}uJLA$lHRx1q-)@oy|wmWt+{J`-jNOhjHUTDw;X`y|W85wDzbuSp`jODmq zsfDZmsK0mLZ#Jew&+!p zt9LI*jnlVxJ4j7%&1bvojvSz#O{#4M&~+N)|FHu$>?B42YTb$tt2f7Obak;iPWL#I2UTwN z={j^LNOfr|?YB`+1nF*$<;``s&}Gu8onF_?U@>`AedC1FJn7zFyOFG^X4!R`r)+sM5cf4x&O&$2Z>i^({A>62N^c8Q-N&}uopWj(z|Q65 zQB^fGj&q@T&LwN=A*{GT&xgi2o1nQ)N$!pKf;UZ~gigu%9xjg!Veo23;o-R8tOHM< zsEHBd!rY;|7~19xhZdt5-!yEODU3PYX>Xk<*HCWtD|4C~D7T%DKv^vm_h-7bO}cIK zS~!BP^N=X2r)$tr|L@%}=juNYr&7_V+FMrV*!J-*bjS43liAwTr;T$i;ZVQVYYh&T zdOlANEtrq*=n60Py$X+7GZ(&2#4JHt2qWgDaDwa@_Mgs8e2eMeKZyK(mg>WE7CO3w zj&7@jS7g65l+U6J?Eojnlq#*q?H5~(++f!QAm3l32bn;r@wk<4nBBY1b2_f|?r13Q z@3g`eLRx6K@H@vYKw97%pcstxYieMFw2}Ei{- z6(cQ}{2MXSg2_EG(t^oviIEmeen*V7VDfunqy>}zC`MW^`9m?%g2^9?krqt;RE)G> z@@HbC1(W|QMp`iWb1~9_$@|4f3nm{FBQ2Qxr5I_!o8=etJ3v!U>H-;}E?Uw^QXAI9G?UVgHFBzU=zyB#iHV zERYuDGoEvX=LOP&e9H5(;VVd=$p7-3H+&UouYAJuis5V6(ns>KT9o1INPm)#cwRHS zfb^mKiRX30Hw4mxe86+T@FLRt@<*OGLd=i2@;=t;@J*!mBv9+M_FG8r%6mL-8eT$r zN8aUmE5wi%$E6_v=CxQ3-Ts--S8bW-;m#c?}QlA zg1o_B9lndC7}A2Z-vhrcU7ikqi}aej&hwtNe=Cp{#BfsL=k0C8s`x9Dr zNrz_??-EE0VLO*N^0Gi$F#H{wiXkn87}A3Ll6HqvwESWd{srydnSKiCCE37SL4imx zt(EJkr|6%=NDJ~3J+yTLbCqR?YcKg9TF2#|#YhW=xCArIrP)*bFJhzx`EN1Of;K;C97dBVXZxm`Ki0FUGWdaNDJ~) zG15XJ=2BRGB1T%U_Gg8;)b1N!@q`#@L4GVoT1doP!uPd5D-0>X-tWhc;vb2T7M`~M zy|J!ObUsPgucx2?-->nrx7rW2r)ay!YClQ)8*TUY_?OsM)LvCTiq-xVtNlmqJ9-{` zyPlt|&WqRA^OtnqqUY1M>v@l!*Ced|VJm;2{v_I-vJ|00@un6-V{iE^9;f;9qg!Y$Z7g5f|Gl%2xaRWa_e^)|$_3<_yj~@}4qw(6s z?`S;N_#Th{@%-Rb<`>N$qWOf&FMPfc&qrLo@;d!pzH%2Sn!ot`#^*aeAA+=C>vxCD zNAo2}3x+r6Q;-(=@-IjW>@p*`7X5z`S3=5yg+9Bfqcl*<{&6H9 zclr8W@^6=~yL|o=u*>J?$^1W!mDMS6 zRhXXLGgM|%GR4!?U#F3%)~MoKy><$GUM^DKowD&y(sEIz!LGhLPCYm)GhkN_P70(2 zndj;1#beZuS7m|9*VU6ps4o{~NgyrQc!vbif~-;Xy881AYS<_8h^qDrYme5k2gFDV z_V;~aqy>|;9)`4FvewCv7EIRq8PbBud#QUNEtvd~7-_-e_n8l*1(UTthqPewyUZKX zg2`|5HINoe-otu8S}^%dYE?)JCcnW7Ls~F-H#Pui!Q@?>6i5puYqU@=t?RB4K_jKg z|0w_VNUF>&X4RdPalFX3xERwfY=blX?Od`(-WEkYNmc(!T#o5)fmh`F)pD_r863

1%94r)u%2jvlX83{Qk)%eSlwT;)25F&sml+vpp?psUMu#$7ujGfP zWuQ4+{;N_expm_zND)Kt}61QvocWMk%6?3rL$U2a)*bKZVnN2hvyM? zKw2n_dWT~(i;NFxLDC}`ZaRjqN_sd0X`$?#)#jlgEl6qyq=nH1#x24cC^YA`mSVZ%TSJgYuC3$z=wYmCiKd+Ua7R*Jqkb z;m~=@T?zM^!*trK)r1E+- z)pQ$Yd&Icukpjen!m^}>_*1sryw4VvGmsWW+yWnBpSiEfG}lPxQ`H6Ab}pgH&3lp> zrf>7Mq(->$DmU*inH`W8%HP-$9tLK$&uXM6L*+-;>B-I6$f{~!IGNE^8K`Co&H@gz zC3M?yJJO9?^&&H{?4=+Vmf37flM^t+>_+)ZVGZ4ke#of%rSPc3 zsp?9k1IKZQrO0zVndNP~B{kw4_tEg(%Mop^=Z2STvgk5)s(8>o{1^)Y$}jcl-r}Pn zEzriOb*DKQPGWSEs&1FA(-8j;9k5|1F#=HeR(#kdf32dci#_Uek285t{$`)9Lw6)q zr!BkRMm-_vdWPlA)VI)O(y1L@*Uextc~pJlh|@gj-e0(ptf^*Mcbcbce|Y^Cs(kW3 zn<$ETeZfNVkXiOD?=(-lmlv-mYBEN_>3!u*dppH5Y?ChT$hB=oJQJGk)ot|7nuXv< zk`+^tkD2mzdL?m8-4ORRQ_ezn%x|gZm;7t@_Iz^-Ro%y`+nsZ29l+7c$)l=jXdLH4 z^PEf8)I*qegPsqKb2dS9os!%e@da<1Ll8^Yk#jKafl!C42MK2Z}R#)Y{< zcQLfh84fK*GrVcoI#U>Oy3^h|Q7)id?pNkC7g4S{AAz!3DDKa63!8MS=CyDHb>|^b zR8Lo=qyFE#Vb0ZmAWkK(QMI|O&avv_UFeSKqbIYxsZSN>oW-GjuUQ@(%rGxDvvuUJ?7s)dd^p`%{T;uYC14dt^a zLp#8UF{OO@arMP=F*8_q0my%)Mh`N9Z1Hh9T{OFQo#)i;j|(z5$BCP`{=IHb@JZ`emEa9GN|eqbO$k6>;Q4kQOpny3Sn{H3FbmC(x1V_zAOp zWk?J8x_>#O1^Q}o(yyp-KMQFgTuU91j~(LY<)MB>%_ho_77BH@$ErzaD`y}rWcyVY zEM0=MP^`MRr@Gijvz&#rkSPwwCVR~iGDr*ga@{3lugMG5av={zA*1=-GR5MHmH_gX Ve4#v2cNw3q!SjF5SD=3d{vY1s-r@iN diff --git a/example/plugins/ztnc/main.go b/example/plugins/ztnc/main.go deleted file mode 100644 index d3182ac..0000000 --- a/example/plugins/ztnc/main.go +++ /dev/null @@ -1,83 +0,0 @@ -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) - uiRouter.EnableDebug = true - - // 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 - targetHandler := uiRouter.Handler() - http.Handle(UI_RELPATH+"/", targetHandler) - - // 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 deleted file mode 100644 index bf82ae0..0000000 --- a/example/plugins/ztnc/mod/database/database.go +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 347b000..0000000 --- a/example/plugins/ztnc/mod/database/database_core.go +++ /dev/null @@ -1,70 +0,0 @@ -//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 deleted file mode 100644 index fd3d8b2..0000000 --- a/example/plugins/ztnc/mod/database/database_openwrt.go +++ /dev/null @@ -1,196 +0,0 @@ -//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 deleted file mode 100644 index 8cf7ec0..0000000 --- a/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 05e708a..0000000 --- a/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 8e60ba0..0000000 --- a/example/plugins/ztnc/mod/database/dbinc/dbinc.go +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 59b9667..0000000 --- a/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index c091684..0000000 --- a/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 006e90d..0000000 --- a/example/plugins/ztnc/mod/ganserv/authkey.go +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index 91ce202..0000000 --- a/example/plugins/ztnc/mod/ganserv/authkeyLinux.go +++ /dev/null @@ -1,37 +0,0 @@ -//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 deleted file mode 100644 index ac5c260..0000000 --- a/example/plugins/ztnc/mod/ganserv/authkeyWin.go +++ /dev/null @@ -1,62 +0,0 @@ -//go:build windows -// +build windows - -package ganserv - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "syscall" - - "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 - } - - 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 deleted file mode 100644 index f81e39b..0000000 --- a/example/plugins/ztnc/mod/ganserv/ganserv.go +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 4ab76da..0000000 --- a/example/plugins/ztnc/mod/ganserv/handlers.go +++ /dev/null @@ -1,504 +0,0 @@ -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 deleted file mode 100644 index 9f4ec73..0000000 --- a/example/plugins/ztnc/mod/ganserv/network.go +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 2002b9f..0000000 --- a/example/plugins/ztnc/mod/ganserv/network_test.go +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 684f597..0000000 --- a/example/plugins/ztnc/mod/ganserv/utils.go +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index fa1fd0b..0000000 --- a/example/plugins/ztnc/mod/ganserv/zerotier.go +++ /dev/null @@ -1,669 +0,0 @@ -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 deleted file mode 100644 index 6adf753..0000000 --- a/example/plugins/ztnc/mod/utils/conv.go +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index e5772a8..0000000 --- a/example/plugins/ztnc/mod/utils/template.go +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 2fe1ffd..0000000 --- a/example/plugins/ztnc/mod/utils/utils.go +++ /dev/null @@ -1,202 +0,0 @@ -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/start.go b/example/plugins/ztnc/start.go deleted file mode 100644 index 1090031..0000000 --- a/example/plugins/ztnc/start.go +++ /dev/null @@ -1,69 +0,0 @@ -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 deleted file mode 100644 index 766644c..0000000 --- a/example/plugins/ztnc/web/details.html +++ /dev/null @@ -1,752 +0,0 @@ - -

- -
- -

- -
-

-
-

- -
- -
-

Settings

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

Custom IP Range

-

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

-
-
- - -
-
- - -
-
-
- - -
-

Members

-

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

-
- - -
-
- - - - - - - - - - - - - - - - - -
AuthAddressNameManaged IPAuthorized SinceVersionRemove
-
-
-

Add Controller as Member

-

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

- - -

-
- diff --git a/example/plugins/ztnc/web/index.html b/example/plugins/ztnc/web/index.html deleted file mode 100644 index 3753ed4..0000000 --- a/example/plugins/ztnc/web/index.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - - - - - Global Area Network | Zoraxy - - - - - - - - - - - - - - -
-
-

Global Area Network

-

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

-
-
-
-
-

- -
Network Controller ID
-

-
-
-
- -
-
0
-
Networks
-
-
-
- -
-
0
-
Connected Nodes
-
-
-
-
-
- -
- -
- - - - - - - - - - - - - - - - -
Network IDNameDescriptionSubnet (Assign Range)NodesActions
No Global Area Network Found on this host
-
-
-
-
- - - \ No newline at end of file