zoraxy/docs/plugins/docs/3. Basic Examples/4. Dynamic Capture Example.md
Toby Chui cd822ed904 Added dynamic capture example
- Added dynamic capture plugin example
- Added dynamic capture plugin doc
- Removed ztnc from plugin example
2025-05-30 21:21:08 +08:00

15 KiB

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](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!