diff --git a/example/plugins/api-call-example/ui.go b/example/plugins/api-call-example/ui.go index 68d49a3..ed76a96 100644 --- a/example/plugins/api-call-example/ui.go +++ b/example/plugins/api-call-example/ui.go @@ -175,9 +175,15 @@ func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Reque API Call Example Plugin UI - + + + - - - + + + +
-

Welcome to the API Call Example Plugin UI

-

Plugin is running on port: ` + strconv.Itoa(config.Port) + `

+
+

Welcome to the API Call Example Plugin UI

+

Plugin is running on port: ` + strconv.Itoa(config.Port) + `

+
+

API Call Examples

- +

✅ Allowed Endpoint (Valid API Key)

Making a GET request to /plugin/api/access/list with a valid API key:

@@ -265,7 +275,7 @@ func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Reque ` + RenderedUnaccessibleResponseHTML + `
- + ` w.Write([]byte(html)) diff --git a/example/plugins/event-subscriber-example/go.mod b/example/plugins/event-subscriber-example/go.mod new file mode 100644 index 0000000..18e0434 --- /dev/null +++ b/example/plugins/event-subscriber-example/go.mod @@ -0,0 +1,3 @@ +module aroz.org/zoraxy/event-subscriber-example + +go 1.24.5 diff --git a/example/plugins/event-subscriber-example/main.go b/example/plugins/event-subscriber-example/main.go new file mode 100644 index 0000000..175fc2f --- /dev/null +++ b/example/plugins/event-subscriber-example/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "bytes" + "fmt" + "net/http" + "sync" + + plugin "aroz.org/zoraxy/event-subscriber-example/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.event_subscriber_example" + UI_PATH = "/ui" + EVENT_PATH = "/notifyme/" +) + +var ( + EventLog = make([]plugin.Event, 0) // A slice to store events + EventLogMutex = &sync.Mutex{} // Mutex to protect access to the event log +) + +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: PLUGIN_ID, + Name: "Event Subscriber Example Plugin", + Author: "Anthony Rubick", + AuthorContact: "", + Description: "An example plugin for event subscriptions, will display all events in the UI", + Type: plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + UIPath: UI_PATH, + + /* Subscriptions Settings */ + SubscriptionPath: "/notifyme", + SubscriptionsEvents: map[plugin.EventName]string{ + // for this example, we will subscribe to all events that exist at time of writing + plugin.EventBlacklistedIPBlocked: "This event is triggered when a blacklisted IP is blocked", + plugin.EventBlacklistToggled: "This event is triggered when the blacklist is toggled for an access rule", + plugin.EventAccessRuleCreated: "This event is triggered when a new access ruleset is created", + }, + }) + + if err != nil { + fmt.Printf("Error serving introspect: %v\n", err) + return + } + + // Start the HTTP server + http.HandleFunc(UI_PATH+"/", func(w http.ResponseWriter, r *http.Request) { + RenderUI(runtimeCfg, w, r) + }) + http.HandleFunc(EVENT_PATH, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var event plugin.Event + + // read the request body + if r.Body == nil || r.ContentLength == 0 { + http.Error(w, "Request body is empty", http.StatusBadRequest) + return + } + defer r.Body.Close() + + buffer := bytes.NewBuffer(make([]byte, 0, r.ContentLength)) + if _, err := buffer.ReadFrom(r.Body); err != nil { + http.Error(w, fmt.Sprintf("Failed to read request body: %v", err), http.StatusBadRequest) + return + } + + // parse the event from the request body + if err := plugin.ParseEvent(buffer.Bytes(), &event); err != nil { + http.Error(w, fmt.Sprintf("Failed to parse event: %v", err), http.StatusBadRequest) + return + } + + // Typically, at this point you would use a switch statement on the event.Name + // to route the event to the appropriate handler. + // + // For this example, we will just store the event and return a success message. + EventLogMutex.Lock() + defer EventLogMutex.Unlock() + if len(EventLog) >= 100 { // Limit the log size to 100 events + EventLog = EventLog[1:] // Remove the oldest event + } + EventLog = append(EventLog, event) // Store the event in the log + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "Event received: %s", event.Name) + + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + serverAddr := fmt.Sprintf("127.0.0.1:%d", runtimeCfg.Port) + fmt.Printf("Starting API Call Example Plugin on %s\n", serverAddr) + http.ListenAndServe(serverAddr, nil) +} diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/README.txt b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/README.txt @@ -0,0 +1,19 @@ +# Zoraxy Plugin + +## Overview +This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components. + +## Instructions + +1. **Copy the Module:** + - Copy the entire `zoraxy_plugin` module to your plugin mod folder. + +2. **Include the Structure:** + - Ensure that you maintain the directory structure and file organization as provided in this module. + +3. **Modify as Needed:** + - Customize the copied module to implement the desired functionality for your plugin. + +## Directory Structure + zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup + embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages \ No newline at end of file diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/dev_webserver.go b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/dev_webserver.go new file mode 100644 index 0000000..9bed106 --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/dev_webserver.go @@ -0,0 +1,145 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" +) + +type PluginUiDebugRouter struct { + PluginID string //The ID of the plugin + TargetDir string //The directory where the UI files are stored + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + EnableDebug bool //Enable debug mode + terminateHandler func() //The handler to be called when the plugin is terminated +} + +// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system +// The targetDir is the directory where the UI files are stored (e.g. ./www) +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiDebugRouter{ + PluginID: pluginID, + TargetDir: targetDir, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from file system + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + //Check if the request is for a directory + //Check if the directory has an index.html file + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html" + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + if _, err := os.Stat(targetFilePath); err == nil { + //Serve the index.html file + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiDebugRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + if p.EnableDebug { + fmt.Print("Request URL:", r.URL.Path, " rewriting to ") + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL.Path = rewrittenURL + r.RequestURI = rewrittenURL + if p.EnableDebug { + fmt.Println(r.URL.Path) + } + + //Serve the file from the file system + fsHandler := http.FileServer(http.Dir(p.TargetDir)) + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the file system UI handler to the target http.ServeMux +func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/dynamic_router.go b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/dynamic_router.go new file mode 100644 index 0000000..22e56be --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/dynamic_router.go @@ -0,0 +1,162 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +/* + + Dynamic Path Handler + +*/ + +type SniffResult int + +const ( + SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress + SniffResultSkip // Skip this plugin and let the next plugin handle the request +) + +type SniffHandler func(*DynamicSniffForwardRequest) SniffResult + +/* +RegisterDynamicSniffHandler registers a dynamic sniff handler for a path +You can decide to accept or skip the request based on the request header and paths +*/ +func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http.ServeMux, handler SniffHandler) { + if !strings.HasSuffix(sniff_ingress, "/") { + sniff_ingress = sniff_ingress + "/" + } + mux.Handle(sniff_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p.enableDebugPrint { + fmt.Println("Request captured by dynamic sniff path: " + r.RequestURI) + } + + // Decode the request payload + jsonBytes, err := io.ReadAll(r.Body) + if err != nil { + if p.enableDebugPrint { + fmt.Println("Error reading request body:", err) + } + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + payload, err := DecodeForwardRequestPayload(jsonBytes) + if err != nil { + if p.enableDebugPrint { + fmt.Println("Error decoding request payload:", err) + fmt.Print("Payload: ") + fmt.Println(string(jsonBytes)) + } + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Get the forwarded request UUID + forwardUUID := r.Header.Get("X-Zoraxy-RequestID") + payload.requestUUID = forwardUUID + payload.rawRequest = r + + sniffResult := handler(&payload) + if sniffResult == SniffResultAccept { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else { + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("SKIP")) + } + })) +} + +// RegisterDynamicCaptureHandle register the dynamic capture ingress path with a handler +func (p *PathRouter) RegisterDynamicCaptureHandle(capture_ingress string, mux *http.ServeMux, handlefunc func(http.ResponseWriter, *http.Request)) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p.enableDebugPrint { + fmt.Println("Request captured by dynamic capture path: " + r.RequestURI) + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, capture_ingress) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + if rewrittenURL == "" { + rewrittenURL = "/" + } + if !strings.HasPrefix(rewrittenURL, "/") { + rewrittenURL = "/" + rewrittenURL + } + r.RequestURI = rewrittenURL + + handlefunc(w, r) + })) +} + +/* + Sniffing and forwarding + + The following functions are here to help with + sniffing and forwarding requests to the dynamic + router. +*/ +// A custom request object to be used in the dynamic sniffing +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"` + + /* Internal use */ + rawRequest *http.Request `json:"-"` + requestUUID string `json:"-"` +} + +// GetForwardRequestPayload returns a DynamicSniffForwardRequest object from an http.Request object +func EncodeForwardRequestPayload(r *http.Request) DynamicSniffForwardRequest { + return DynamicSniffForwardRequest{ + Method: r.Method, + Hostname: r.Host, + URL: r.URL.String(), + Header: r.Header, + RemoteAddr: r.RemoteAddr, + Host: r.Host, + RequestURI: r.RequestURI, + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + rawRequest: r, + } +} + +// DecodeForwardRequestPayload decodes JSON bytes into a DynamicSniffForwardRequest object +func DecodeForwardRequestPayload(jsonBytes []byte) (DynamicSniffForwardRequest, error) { + var payload DynamicSniffForwardRequest + err := json.Unmarshal(jsonBytes, &payload) + if err != nil { + return DynamicSniffForwardRequest{}, err + } + return payload, nil +} + +// GetRequest returns the original http.Request object, for debugging purposes +func (dsfr *DynamicSniffForwardRequest) GetRequest() *http.Request { + return dsfr.rawRequest +} + +// GetRequestUUID returns the request UUID +// if this UUID is empty string, that might indicate the request +// is not coming from the dynamic router +func (dsfr *DynamicSniffForwardRequest) GetRequestUUID() string { + return dsfr.requestUUID +} diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..b64318f --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,156 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + TargetFs *embed.FS //The embed.FS where the UI files are stored + TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + EnableDebug bool //Enable debug mode + terminateHandler func() //The handler to be called when the plugin is terminated +} + +// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + // Check if the directory has an index.html file + indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html" + indexFilePath = p.TargetFsPrefix + "/" + indexFilePath + indexFilePath = strings.TrimPrefix(indexFilePath, "/") + indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath) + if err == nil { + body := string(indexFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + if p.EnableDebug { + fmt.Print("Request URL:", r.URL.Path, " rewriting to ") + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + r.RequestURI = rewrittenURL + if p.EnableDebug { + fmt.Println(r.URL.Path) + } + + //Serve the file from the embed.FS + subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) + if err != nil { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the embed UI handler to the target http.ServeMux +func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/event.go b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/event.go new file mode 100644 index 0000000..0e0924e --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/event.go @@ -0,0 +1,120 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" +) + +// EventName represents the type of event +type EventName string + +// EventPayload interface for all event payloads +type EventPayload interface { + // GetName returns the event type + GetName() EventName +} + +// Event represents a system event +type Event struct { + Name EventName `json:"name"` + Timestamp int64 `json:"timestamp"` // Unix timestamp + Data EventPayload `json:"data"` +} + +const ( + // EventBlacklistedIPBlocked is emitted when a blacklisted IP is blocked + EventBlacklistedIPBlocked EventName = "blacklistedIpBlocked" + // EventBlacklistToggled is emitted when the blacklist is toggled for an access rule + EventBlacklistToggled EventName = "blacklistToggled" + // EventAccessRuleCreated is emitted when a new access ruleset is created + EventAccessRuleCreated EventName = "accessRuleCreated" + + // Add more event types as needed +) + +// BlacklistedIPBlockedEvent represents an event when a blacklisted IP is blocked +type BlacklistedIPBlockedEvent struct { + IP string `json:"ip"` + Comment string `json:"comment"` + RequestedURL string `json:"requested_url"` + Hostname string `json:"hostname"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` +} + +func (e *BlacklistedIPBlockedEvent) GetName() EventName { + return EventBlacklistedIPBlocked +} + +// BlacklistToggledEvent represents an event when the blacklist is disabled for an access rule +type BlacklistToggledEvent struct { + RuleID string `json:"rule_id"` + Enabled bool `json:"enabled"` // Whether the blacklist is enabled or disabled +} + +func (e *BlacklistToggledEvent) GetName() EventName { + return EventBlacklistToggled +} + +// AccessRuleCreatedEvent represents an event when a new access ruleset is created +type AccessRuleCreatedEvent struct { + ID string `json:"id"` + Name string `json:"name"` + Desc string `json:"desc"` + BlacklistEnabled bool `json:"blacklist_enabled"` + WhitelistEnabled bool `json:"whitelist_enabled"` +} + +func (e *AccessRuleCreatedEvent) GetName() EventName { + return EventAccessRuleCreated +} + +// ParseEvent parses a JSON byte slice into an Event struct +func ParseEvent(jsonData []byte, event *Event) error { + // First, determine the event type, and parse shared fields, from the JSON data + var temp struct { + Name EventName `json:"name"` + Timestamp int64 `json:"timestamp"` + } + if err := json.Unmarshal(jsonData, &temp); err != nil { + return err + } + + // Set the event name and timestamp + event.Name = temp.Name + event.Timestamp = temp.Timestamp + + // Now, based on the event type, unmarshal the specific payload + switch temp.Name { + case EventBlacklistedIPBlocked: + type tempData struct { + Data BlacklistedIPBlockedEvent `json:"data"` + } + var payload tempData + if err := json.Unmarshal(jsonData, &payload); err != nil { + return err + } + event.Data = &payload.Data + case EventBlacklistToggled: + type tempData struct { + Data BlacklistToggledEvent `json:"data"` + } + var payload tempData + if err := json.Unmarshal(jsonData, &payload); err != nil { + return err + } + event.Data = &payload.Data + case EventAccessRuleCreated: + type tempData struct { + Data AccessRuleCreatedEvent `json:"data"` + } + var payload tempData + if err := json.Unmarshal(jsonData, &payload); err != nil { + return err + } + event.Data = &payload.Data + default: + return fmt.Errorf("unknown event: %s, %v", temp.Name, jsonData) + } + return nil +} diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/static_router.go b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/static_router.go new file mode 100644 index 0000000..f4abcb7 --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/static_router.go @@ -0,0 +1,105 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "sort" + "strings" +) + +type PathRouter struct { + enableDebugPrint bool + pathHandlers map[string]http.Handler + defaultHandler http.Handler +} + +// NewPathRouter creates a new PathRouter +func NewPathRouter() *PathRouter { + return &PathRouter{ + enableDebugPrint: false, + pathHandlers: make(map[string]http.Handler), + } +} + +// RegisterPathHandler registers a handler for a path +func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) { + path = strings.TrimSuffix(path, "/") + p.pathHandlers[path] = handler +} + +// RemovePathHandler removes a handler for a path +func (p *PathRouter) RemovePathHandler(path string) { + delete(p.pathHandlers, path) +} + +// SetDefaultHandler sets the default handler for the router +// This handler will be called if no path handler is found +func (p *PathRouter) SetDefaultHandler(handler http.Handler) { + p.defaultHandler = handler +} + +// SetDebugPrintMode sets the debug print mode +func (p *PathRouter) SetDebugPrintMode(enable bool) { + p.enableDebugPrint = enable +} + +// StartStaticCapture starts the static capture ingress +func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.staticCaptureServeHTTP(w, r) + })) +} + +// staticCaptureServeHTTP serves the static capture path using user defined handler +func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) { + capturePath := r.Header.Get("X-Zoraxy-Capture") + if capturePath != "" { + if p.enableDebugPrint { + fmt.Printf("Using capture path: %s\n", capturePath) + } + originalURI := r.Header.Get("X-Zoraxy-Uri") + r.URL.Path = originalURI + if handler, ok := p.pathHandlers[capturePath]; ok { + handler.ServeHTTP(w, r) + return + } + } + p.defaultHandler.ServeHTTP(w, r) +} + +func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) { + if p.enableDebugPrint { + fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path) + keys := make([]string, 0, len(r.Header)) + for key := range r.Header { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range r.Header[key] { + fmt.Printf("%s: %s\n", key, value) + } + } + + fmt.Printf("\n\n**Request Details**\n\n") + fmt.Printf("Method: %s\n", r.Method) + fmt.Printf("URL: %s\n", r.URL.String()) + fmt.Printf("Proto: %s\n", r.Proto) + fmt.Printf("Host: %s\n", r.Host) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + fmt.Printf("ContentLength: %d\n", r.ContentLength) + fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding) + fmt.Printf("Close: %v\n", r.Close) + fmt.Printf("Form: %v\n", r.Form) + fmt.Printf("PostForm: %v\n", r.PostForm) + fmt.Printf("MultipartForm: %v\n", r.MultipartForm) + fmt.Printf("Trailer: %v\n", r.Trailer) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + + } +} diff --git a/example/plugins/event-subscriber-example/mod/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..08efe3c --- /dev/null +++ b/example/plugins/event-subscriber-example/mod/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,187 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type StaticCaptureRule struct { + CapturePath string `json:"capture_path"` + //To be expanded +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` + DevelopmentBuild bool `json:"development_build"` //Whether the Zoraxy is a development build or not +} + +type PermittedAPIEndpoint struct { + Method string `json:"method"` //HTTP method for the API endpoint (e.g., GET, POST) + Endpoint string `json:"endpoint"` //The API endpoint that the plugin can access + Reason string `json:"reason"` //The reason why the plugin needs to access this endpoint +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Static Capture Settings + + Once plugin is enabled these rules always applies to the enabled HTTP Proxy rule + This is faster than dynamic capture, but less flexible + */ + StaticCapturePaths []StaticCaptureRule `json:"static_capture_paths"` //Static capture paths of your plugin, see Zoraxy documentation for more details + StaticCaptureIngress string `json:"static_capture_ingress"` //Static capture ingress path of your plugin (e.g. /s_handler) + + /* + Dynamic Capture Settings + + Once plugin is enabled, these rules will be captured and forward to plugin sniff + if the plugin sniff returns 280, the traffic will be captured + otherwise, the traffic will be forwarded to the next plugin + This is slower than static capture, but more flexible + */ + DynamicCaptureSniff string `json:"dynamic_capture_sniff"` //Dynamic capture sniff path of your plugin (e.g. /d_sniff) + DynamicCaptureIngress string `json:"dynamic_capture_ingress"` //Dynamic capture ingress path of your plugin (e.g. /d_handler) + + /* UI Path for your plugin */ + UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + SubscriptionsEvents map[EventName]string `json:"subscriptions_events"` //Subscriptions events of your plugin, paired with comments describing how the event is used, see Zoraxy documentation for more details + + /* API Access Control */ + PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"` //List of API endpoints this plugin can access, and a description of why the plugin needs to access this endpoint +} + +/* +ServeIntroSpect Function + +This function will check if the plugin is initialized with -introspect flag, +if so, it will print the intro spect and exit + +Place this function at the beginning of your plugin main function +*/ +func ServeIntroSpect(pluginSpect *IntroSpect) { + if len(os.Args) > 1 && os.Args[1] == "-introspect" { + //Print the intro spect and exit + jsonData, _ := json.MarshalIndent(pluginSpect, "", " ") + fmt.Println(string(jsonData)) + os.Exit(0) + } +} + +/* +ConfigureSpec Payload + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values + APIKey string `json:"api_key,omitempty"` //API key for accessing Zoraxy APIs, if the plugin has permitted endpoints + ZoraxyPort int `json:"zoraxy_port,omitempty"` //The port that Zoraxy is running on, used for making API calls to Zoraxy + //To be expanded +} + +/* +RecvExecuteConfigureSpec Function + +This function will read the configure spec from Zoraxy +and return the ConfigureSpec object + +Place this function after ServeIntroSpect function in your plugin main function +*/ +func RecvConfigureSpec() (*ConfigureSpec, error) { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-configure=") { + var configSpec ConfigureSpec + if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil { + return nil, err + } + return &configSpec, nil + } else if arg == "-configure" { + var configSpec ConfigureSpec + var nextArg string + if len(os.Args) > i+1 { + nextArg = os.Args[i+1] + if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("no port specified after -configure flag") + } + return &configSpec, nil + } + } + return nil, fmt.Errorf("no -configure flag found") +} + +/* +ServeAndRecvSpec Function + +This function will serve the intro spect and return the configure spec +See the ServeIntroSpect and RecvConfigureSpec for more details +*/ +func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { + ServeIntroSpect(pluginSpect) + return RecvConfigureSpec() +} diff --git a/example/plugins/event-subscriber-example/ui.go b/example/plugins/event-subscriber-example/ui.go new file mode 100644 index 0000000..c212cb5 --- /dev/null +++ b/example/plugins/event-subscriber-example/ui.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "net/http" + "time" + + plugin "aroz.org/zoraxy/event-subscriber-example/mod/zoraxy_plugin" +) + +func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Request) { + // Render the UI for the plugin + var eventLogHTML string + if len(EventLog) == 0 { + eventLogHTML = "

No events received yet
Try toggling a blacklist or something like that

" + } else { + EventLogMutex.Lock() + defer EventLogMutex.Unlock() + for _, event := range EventLog { + rawEventData, _ := json.Marshal(event) + + eventLogHTML += "
" + eventLogHTML += "

" + string(event.Name) + " at " + time.Unix(event.Timestamp, 0).Local().Format(time.RFC3339) + "

" + eventLogHTML += "
" + eventLogHTML += "

Event Data:

" + eventLogHTML += "
" + string(rawEventData) + "
" + eventLogHTML += "
" + } + + } + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + html := ` + + + + Event Log + + + + + + + + + + + + +
+ +

Event Log

+
` + eventLogHTML + `
+ + + ` + w.Write([]byte(html)) +} diff --git a/src/accesslist.go b/src/accesslist.go index 5fd0779..46a4861 100644 --- a/src/accesslist.go +++ b/src/accesslist.go @@ -10,6 +10,8 @@ import ( "github.com/microcosm-cc/bluemonday" "imuslab.com/zoraxy/mod/access" + "imuslab.com/zoraxy/mod/eventsystem" + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" "imuslab.com/zoraxy/mod/utils" ) @@ -97,6 +99,17 @@ func handleCreateAccessRule(w http.ResponseWriter, r *http.Request) { return } + // emit an event for the new access rule creation + eventsystem.Publisher.Emit( + &events.AccessRuleCreatedEvent{ + ID: ruleUUID, + Name: ruleName, + Desc: ruleDesc, + BlacklistEnabled: false, + WhitelistEnabled: false, + }, + ) + utils.SendOK(w) } @@ -359,6 +372,11 @@ func handleBlacklistEnable(w http.ResponseWriter, r *http.Request) { return } + eventsystem.Publisher.Emit(&events.BlacklistToggledEvent{ + RuleID: ruleID, + Enabled: rule.BlacklistEnabled, + }) + utils.SendOK(w) } } diff --git a/src/mod/access/access.go b/src/mod/access/access.go index b4c8fe1..a5bccd7 100644 --- a/src/mod/access/access.go +++ b/src/mod/access/access.go @@ -60,6 +60,7 @@ func NewAccessController(options *Options) (*Controller, error) { //Create one js, _ := json.MarshalIndent(defaultAccessRule, "", " ") os.WriteFile(defaultRuleSettingFile, js, 0775) + } //Generate a controller object @@ -191,6 +192,7 @@ func (c *Controller) AddNewAccessRule(newRule *AccessRule) error { //Save rule to file newRule.SaveChanges() + return nil } diff --git a/src/mod/access/blacklist.go b/src/mod/access/blacklist.go index 7daab35..aaa7224 100644 --- a/src/mod/access/blacklist.go +++ b/src/mod/access/blacklist.go @@ -1,6 +1,7 @@ package access import ( + "fmt" "strings" "imuslab.com/zoraxy/mod/netutils" @@ -92,3 +93,42 @@ func (s *AccessRule) IsIPBlacklisted(ipAddr string) bool { return false } + +// GetBlacklistedIPComment returns the comment for a blacklisted IP address +// Searches blacklist for a Country (if country-code provided), IP address, or CIDR that matches the IP address +// returns error if not found +func (s *AccessRule) GetBlacklistedIPComment(ipAddr string) (string, error) { + if countryInfo, err := s.parent.Options.GeoDB.ResolveCountryCodeFromIP(ipAddr); err == nil { + CCBlacklist := *s.BlackListContryCode + countryCode := strings.ToLower(countryInfo.CountryIsoCode) + + if comment, ok := CCBlacklist[countryCode]; ok { + return comment, nil + } + } + + IPBlacklist := *s.BlackListIP + if comment, ok := IPBlacklist[ipAddr]; ok { + return comment, nil + } + + //Check for CIDR + for ipOrCIDR, comment := range IPBlacklist { + wildcardMatch := netutils.MatchIpWildcard(ipAddr, ipOrCIDR) + if wildcardMatch { + return comment, nil + } + + cidrMatch := netutils.MatchIpCIDR(ipAddr, ipOrCIDR) + if cidrMatch { + return comment, nil + } + } + + return "", fmt.Errorf("IP %s not found in blacklist", ipAddr) +} + +// GetParent returns the parent controller +func (s *AccessRule) GetParent() *Controller { + return s.parent +} diff --git a/src/mod/dynamicproxy/access.go b/src/mod/dynamicproxy/access.go index 1845612..dca919c 100644 --- a/src/mod/dynamicproxy/access.go +++ b/src/mod/dynamicproxy/access.go @@ -6,7 +6,9 @@ import ( "path/filepath" "imuslab.com/zoraxy/mod/access" + "imuslab.com/zoraxy/mod/eventsystem" "imuslab.com/zoraxy/mod/netutils" + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" ) // Handle access check (blacklist / whitelist), return true if request is handled (aka blocked) @@ -43,6 +45,23 @@ func accessRequestBlocked(accessRule *access.AccessRule, templateDirectory strin w.Write(template) } + // Emit blacklisted IP blocked event + // Get the comment for this IP + comment, err := accessRule.GetBlacklistedIPComment(clientIpAddr) + if err != nil { + comment = "blacklisted" + } + eventsystem.Publisher.Emit( + &events.BlacklistedIPBlockedEvent{ + IP: clientIpAddr, + Comment: comment, + RequestedURL: r.URL.String(), + Hostname: r.Host, + UserAgent: r.Header.Get("User-Agent"), + Method: r.Method, + }, + ) + return true, "blacklist" } diff --git a/src/mod/eventsystem/event_system.go b/src/mod/eventsystem/event_system.go new file mode 100644 index 0000000..7c4f820 --- /dev/null +++ b/src/mod/eventsystem/event_system.go @@ -0,0 +1,125 @@ +package eventsystem + +import ( + "sync" + "time" + + "imuslab.com/zoraxy/mod/info/logger" + // "imuslab.com/zoraxy/mod/plugins" + + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" +) + +type ListenerID string +type Listener interface { + Notify(event events.Event) error + GetID() ListenerID +} + +// eventManager manages event subscriptions and dispatching events to listeners +type eventManager struct { + subscriptions map[events.EventName][]ListenerID // EventType -> []Subscriber, tracks which events each listener is subscribed to + subscribers map[ListenerID]Listener // ListenerID -> Listener, tracks all registered listeners + logger *logger.Logger // Logger for the event manager + mutex sync.RWMutex // Mutex for concurrent access +} + +var ( + // Publisher is the singleton instance of the event manager + Publisher *eventManager + once sync.Once +) + +// InitEventSystem initializes the event manager with the plugin manager +func InitEventSystem(logger *logger.Logger) { + once.Do(func() { + Publisher = &eventManager{ + subscriptions: make(map[events.EventName][]ListenerID), + subscribers: make(map[ListenerID]Listener), + logger: logger, + } + }) +} + +// RegisterSubscriberToEvent adds a listener to the subscription list for an event type +func (em *eventManager) RegisterSubscriberToEvent(subscriber Listener, eventType events.EventName) error { + em.mutex.Lock() + defer em.mutex.Unlock() + + if _, exists := em.subscriptions[eventType]; !exists { + em.subscriptions[eventType] = []ListenerID{} + } + + // Register the listener if not already registered + listenerID := subscriber.GetID() + em.subscribers[listenerID] = subscriber + + // Check if already subscribed to the event + for _, id := range em.subscriptions[eventType] { + if id == listenerID { + return nil // Already subscribed + } + } + + // Register the listener to the event + em.subscriptions[eventType] = append(em.subscriptions[eventType], listenerID) + + return nil +} + +// Deregister removes a listener from all event subscriptions, and +// also removes the listener from the list of subscribers. +func (em *eventManager) UnregisterSubscriber(listenerID ListenerID) error { + em.mutex.Lock() + defer em.mutex.Unlock() + + for eventType, subscribers := range em.subscriptions { + for i, id := range subscribers { + if id == listenerID { + em.subscriptions[eventType] = append(subscribers[:i], subscribers[i+1:]...) + break + } + } + } + delete(em.subscribers, listenerID) + + return nil +} + +// Emit dispatches an event to all subscribed listeners +func (em *eventManager) Emit(payload events.EventPayload) error { + eventName := payload.GetName() + + em.mutex.RLock() + defer em.mutex.RUnlock() + subscribers, exists := em.subscriptions[eventName] + + if !exists || len(subscribers) == 0 { + return nil // No subscribers + } + + // Create the event + event := events.Event{ + Name: eventName, + Timestamp: time.Now().Unix(), + Data: payload, + } + + // Dispatch to all subscribers asynchronously + for _, listenerID := range subscribers { + listener, exists := em.subscribers[listenerID] + + if !exists { + em.logger.PrintAndLog("event-system", "Failed to get listener for event dispatch, removing "+string(listenerID)+" from subscriptions", nil) + continue + } + + go func(l Listener) { + if err := l.Notify(event); err != nil { + em.logger.PrintAndLog("event-system", "Failed to dispatch `"+string(event.Name)+"` event to listener "+string(listenerID), err) + } + }(listener) + } + + return nil +} diff --git a/src/mod/eventsystem/event_system_test.go b/src/mod/eventsystem/event_system_test.go new file mode 100644 index 0000000..432ae5a --- /dev/null +++ b/src/mod/eventsystem/event_system_test.go @@ -0,0 +1,113 @@ +package eventsystem + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" +) + +// Test (de)serialization of events +func TestEventDeSerialization(t *testing.T) { + type SerializationTest struct { + name string + event events.Event + expectedJson string + } + + timestamp := time.Now().Unix() + + tests := []SerializationTest{ + { + name: "BlacklistedIPBlocked", + event: events.Event{ + Name: events.EventBlacklistedIPBlocked, + Timestamp: timestamp, + Data: &events.BlacklistedIPBlockedEvent{ + IP: "192.168.1.1", + Comment: "Test comment", + RequestedURL: "http://example.com", + Hostname: "example.com", + UserAgent: "TestUserAgent", + Method: "GET", + }, + }, + expectedJson: `{"name":"blacklistedIpBlocked","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"data":{"ip":"192.168.1.1","comment":"Test comment","requested_url":"http://example.com","hostname":"example.com","user_agent":"TestUserAgent","method":"GET"}}`, + }, + { + name: "BlacklistToggled", + event: events.Event{ + Name: events.EventBlacklistToggled, + Timestamp: timestamp, + Data: &events.BlacklistToggledEvent{ + RuleID: "rule123", + Enabled: true, + }, + }, + expectedJson: `{"name":"blacklistToggled","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"data":{"rule_id":"rule123","enabled":true}}`, + }, + { + name: "AccessRuleCreated", + event: events.Event{ + Name: events.EventAccessRuleCreated, + Timestamp: timestamp, + Data: &events.AccessRuleCreatedEvent{ + ID: "rule456", + Name: "New Access Rule", + Desc: "A dummy access rule", + BlacklistEnabled: true, + WhitelistEnabled: false, + }, + }, + expectedJson: `{"name":"accessRuleCreated","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"data":{"id":"rule456","name":"New Access Rule","desc":"A dummy access rule","blacklist_enabled":true,"whitelist_enabled":false}}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Serialize the event + jsonData, err := json.Marshal(test.event) + if err != nil { + t.Fatalf("Failed to serialize event: %v", err) + } + + // Compare the serialized JSON with the expected JSON + if string(jsonData) != test.expectedJson { + t.Fatalf("Unexpected JSON output.\nGot: %s\nWant: %s", jsonData, test.expectedJson) + } + + // Deserialize the JSON back into an event + var deserializedEvent events.Event + if err := events.ParseEvent(jsonData, &deserializedEvent); err != nil { + t.Fatalf("Failed to parse event: %v", err) + } + + // Compare the original event with the deserialized event + if deserializedEvent.Name != test.event.Name || deserializedEvent.Timestamp != test.event.Timestamp { + t.Fatalf("Deserialized event does not match original.\nGot: %+v\nWant: %+v", deserializedEvent, test.event) + } + + switch data := deserializedEvent.Data.(type) { + case *events.BlacklistedIPBlockedEvent: + originalData, ok := test.event.Data.(*events.BlacklistedIPBlockedEvent) + if !ok || *data != *originalData { + t.Fatalf("Deserialized BlacklistedIPBlockedEvent does not match original.\nGot: %+v\nWant: %+v", data, originalData) + } + case *events.AccessRuleCreatedEvent: + originalData, ok := test.event.Data.(*events.AccessRuleCreatedEvent) + if !ok || *data != *originalData { + t.Fatalf("Deserialized AccessRuleCreatedEvent does not match original.\nGot: %+v\nWant: %+v", data, originalData) + } + case *events.BlacklistToggledEvent: + originalData, ok := test.event.Data.(*events.BlacklistToggledEvent) + if !ok || *data != *originalData { + t.Fatalf("Deserialized BlacklistToggledEvent does not match original.\nGot: %+v\nWant: %+v", data, originalData) + } + default: + t.Fatalf("Unknown event type: %T", data) + } + }) + } +} diff --git a/src/mod/plugins/eventlistener.go b/src/mod/plugins/eventlistener.go new file mode 100644 index 0000000..21d3244 --- /dev/null +++ b/src/mod/plugins/eventlistener.go @@ -0,0 +1,83 @@ +// implements `eventsystem.Listener` for Plugin + +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "imuslab.com/zoraxy/mod/eventsystem" + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" +) + +func (p *Plugin) GetID() eventsystem.ListenerID { + return eventsystem.ListenerID(p.Spec.ID) +} + +// Send an event to the plugin +func (p *Plugin) Notify(event events.Event) error { + // Handle the event notification + if !p.Enabled || p.AssignedPort == 0 { + return fmt.Errorf("plugin %s is not running", p.Spec.ID) + } + + subscriptionPath := p.Spec.SubscriptionPath + if subscriptionPath == "" { + return fmt.Errorf("plugin %s has no subscription path configured", p.Spec.ID) + } + + if !strings.HasPrefix(subscriptionPath, "/") { + subscriptionPath = "/" + subscriptionPath + } + + // Prepare the URL + url := fmt.Sprintf("http://127.0.0.1:%d%s/%s", p.AssignedPort, subscriptionPath, event.Name) + + // Marshal the event to JSON + eventData, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal event: %w", err) + } + + // Create HTTP request + req, err := http.NewRequest("POST", url, bytes.NewBuffer(eventData)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Zoraxy-Event-Type", string(event.Name)) + + // Send the request with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return errors.New("Failed to send event: " + err.Error()) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody := fmt.Errorf("no response body") + if resp.ContentLength > 0 { + buffer := bytes.NewBuffer(make([]byte, 0, resp.ContentLength)) + _, respErr := buffer.ReadFrom(resp.Body) + if respErr != nil { + respBody = fmt.Errorf("failed to read response body: %v", respErr) + } else { + respBody = fmt.Errorf("response body: %s", buffer.String()) + } + } + + return fmt.Errorf("plugin %s returned non-200 status for event `%s` (%s): %w", p.Spec.ID, event.Name, resp.Status, respBody) + } + + return nil +} diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go index cceff11..cf8282f 100644 --- a/src/mod/plugins/lifecycle.go +++ b/src/mod/plugins/lifecycle.go @@ -14,7 +14,9 @@ import ( "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/eventsystem" zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" ) func (m *Manager) StartPlugin(pluginID string) error { @@ -151,6 +153,19 @@ func (m *Manager) StartPlugin(pluginID string) error { thisPlugin.process = cmd thisPlugin.Enabled = true + // Register event subscriptions + if thisPlugin.Spec.SubscriptionsEvents != nil { + for eventName := range thisPlugin.Spec.SubscriptionsEvents { + eventType := events.EventName(eventName) + err := eventsystem.Publisher.RegisterSubscriberToEvent(thisPlugin, eventType) + if err != nil { + m.Log("Failed to subscribe plugin "+thisPlugin.Spec.Name+" to event "+string(eventName), err) + } else { + m.Log("Subscribed plugin "+thisPlugin.Spec.Name+" to event "+string(eventName), nil) + } + } + } + //Create a new static forwarder router for each of the static capture paths thisPlugin.StartAllStaticPathRouters() @@ -288,6 +303,11 @@ func (m *Manager) StopPlugin(pluginID string) error { if err != nil { m.Log("Failed to revoke API keys for plugin "+thisPlugin.Spec.Name, err) } + //Unsubscribe from all events + err = eventsystem.Publisher.UnregisterSubscriber(eventsystem.ListenerID(thisPlugin.Spec.ID)) + if err != nil { + m.Log("Failed to unsubscribe plugin "+thisPlugin.Spec.Name+" from events", err) + } return nil } diff --git a/src/mod/plugins/typdef.go b/src/mod/plugins/typdef.go index befdf97..7cf4326 100644 --- a/src/mod/plugins/typdef.go +++ b/src/mod/plugins/typdef.go @@ -60,7 +60,7 @@ type Manager struct { LoadedPlugins map[string]*Plugin //Storing *Plugin tagPluginMap sync.Map //Storing *radix.Tree for each plugin tag tagPluginListMutex sync.RWMutex //Mutex for the tagPluginList - tagPluginList map[string][]*Plugin //Storing the plugin list for each tag, only concurrent READ is allowed + tagPluginList map[string][]*Plugin //Storing the plugin list for each tag, only concurrent read is allowed Options *ManagerOptions PluginHash map[string]string //The hash of the plugin file, used to check if the plugin file is changed diff --git a/src/mod/plugins/zoraxy_plugin/events/events.go b/src/mod/plugins/zoraxy_plugin/events/events.go new file mode 100644 index 0000000..3f54b2e --- /dev/null +++ b/src/mod/plugins/zoraxy_plugin/events/events.go @@ -0,0 +1,120 @@ +package events + +import ( + "encoding/json" + "fmt" +) + +// EventName represents the type of event +type EventName string + +// EventPayload interface for all event payloads +type EventPayload interface { + // GetName returns the event type + GetName() EventName +} + +// Event represents a system event +type Event struct { + Name EventName `json:"name"` + Timestamp int64 `json:"timestamp"` // Unix timestamp + Data EventPayload `json:"data"` +} + +const ( + // EventBlacklistedIPBlocked is emitted when a blacklisted IP is blocked + EventBlacklistedIPBlocked EventName = "blacklistedIpBlocked" + // EventBlacklistToggled is emitted when the blacklist is toggled for an access rule + EventBlacklistToggled EventName = "blacklistToggled" + // EventAccessRuleCreated is emitted when a new access ruleset is created + EventAccessRuleCreated EventName = "accessRuleCreated" + + // Add more event types as needed +) + +// BlacklistedIPBlockedEvent represents an event when a blacklisted IP is blocked +type BlacklistedIPBlockedEvent struct { + IP string `json:"ip"` + Comment string `json:"comment"` + RequestedURL string `json:"requested_url"` + Hostname string `json:"hostname"` + UserAgent string `json:"user_agent"` + Method string `json:"method"` +} + +func (e *BlacklistedIPBlockedEvent) GetName() EventName { + return EventBlacklistedIPBlocked +} + +// BlacklistToggledEvent represents an event when the blacklist is disabled for an access rule +type BlacklistToggledEvent struct { + RuleID string `json:"rule_id"` + Enabled bool `json:"enabled"` // Whether the blacklist is enabled or disabled +} + +func (e *BlacklistToggledEvent) GetName() EventName { + return EventBlacklistToggled +} + +// AccessRuleCreatedEvent represents an event when a new access ruleset is created +type AccessRuleCreatedEvent struct { + ID string `json:"id"` + Name string `json:"name"` + Desc string `json:"desc"` + BlacklistEnabled bool `json:"blacklist_enabled"` + WhitelistEnabled bool `json:"whitelist_enabled"` +} + +func (e *AccessRuleCreatedEvent) GetName() EventName { + return EventAccessRuleCreated +} + +// ParseEvent parses a JSON byte slice into an Event struct +func ParseEvent(jsonData []byte, event *Event) error { + // First, determine the event type, and parse shared fields, from the JSON data + var temp struct { + Name EventName `json:"name"` + Timestamp int64 `json:"timestamp"` + } + if err := json.Unmarshal(jsonData, &temp); err != nil { + return err + } + + // Set the event name and timestamp + event.Name = temp.Name + event.Timestamp = temp.Timestamp + + // Now, based on the event type, unmarshal the specific payload + switch temp.Name { + case EventBlacklistedIPBlocked: + type tempData struct { + Data BlacklistedIPBlockedEvent `json:"data"` + } + var payload tempData + if err := json.Unmarshal(jsonData, &payload); err != nil { + return err + } + event.Data = &payload.Data + case EventBlacklistToggled: + type tempData struct { + Data BlacklistToggledEvent `json:"data"` + } + var payload tempData + if err := json.Unmarshal(jsonData, &payload); err != nil { + return err + } + event.Data = &payload.Data + case EventAccessRuleCreated: + type tempData struct { + Data AccessRuleCreatedEvent `json:"data"` + } + var payload tempData + if err := json.Unmarshal(jsonData, &payload); err != nil { + return err + } + event.Data = &payload.Data + default: + return fmt.Errorf("unknown event: %s, %v", temp.Name, jsonData) + } + return nil +} diff --git a/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go index 5398087..17180bb 100644 --- a/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go +++ b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "strings" + + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin/events" ) /* @@ -102,8 +104,8 @@ type IntroSpect struct { UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI /* Subscriptions Settings */ - SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered - SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, paired with comments describing how the event is used, see Zoraxy documentation for more details + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + SubscriptionsEvents map[events.EventName]string `json:"subscriptions_events"` //Subscriptions events of your plugin, paired with comments describing how the event is used, see Zoraxy documentation for more details /* API Access Control */ PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"` //List of API endpoints this plugin can access, and a description of why the plugin needs to access this endpoint @@ -167,12 +169,12 @@ func RecvConfigureSpec() (*ConfigureSpec, error) { return nil, err } } else { - return nil, fmt.Errorf("No port specified after -configure flag") + return nil, fmt.Errorf("no port specified after -configure flag") } return &configSpec, nil } } - return nil, fmt.Errorf("No -configure flag found") + return nil, fmt.Errorf("no -configure flag found") } /* diff --git a/src/start.go b/src/start.go index e5e5c83..d1afd93 100644 --- a/src/start.go +++ b/src/start.go @@ -11,6 +11,7 @@ import ( "time" "imuslab.com/zoraxy/mod/auth/sso/oauth2" + "imuslab.com/zoraxy/mod/eventsystem" "github.com/gorilla/csrf" "imuslab.com/zoraxy/mod/access" @@ -369,6 +370,11 @@ func startupSequence() { HotReloadInterval: 5, //seconds }) + /* + Event Manager + */ + eventsystem.InitEventSystem(SystemWideLogger) + //Sync latest plugin list from the plugin store go func() { err = pluginManager.UpdateDownloadablePluginList() diff --git a/src/web/snippet/pluginInfo.html b/src/web/snippet/pluginInfo.html index 71e514d..0bf87c9 100644 --- a/src/web/snippet/pluginInfo.html +++ b/src/web/snippet/pluginInfo.html @@ -136,6 +136,11 @@ The relative path of the web UI + + Registered Subscribed Event Path + Path where subscribed events are sent + Not registered +
@@ -159,6 +164,23 @@ API keys are generated automatically by Zoraxy when a plugin with permitted API endpoints is enabled.

+

Plugin IntroSpect Event Subscriptions

+

The following events are subscribed to by this plugin and will be sent to the plugin's event subscription path:

+ + + + + + + + + + + + + +
Event TypeComment
No subscribed events
+
@@ -234,11 +256,16 @@ if (registeredUIProxyPath == null || registeredUIProxyPath == "") { registeredUIProxyPath = "No UI registered"; } + let subscriptionPath = data.Spec.subscription_path; + if (subscriptionPath == null || subscriptionPath == "") { + subscriptionPath = "Not registered"; + } $("#static_capture_ingress").text(staticCaptureIngress); $("#dynamic_capture_sniffing_path").text(dynamicCaptureSniffingPath); $("#dynamic_capture_ingress").text(dynamicCaptureIngress); $("#registered_ui_proxy_path").text(registeredUIProxyPath); + $("#registered_subscription_path").text(subscriptionPath); //Update permitted API endpoints let apiEndpoints = data.Spec.permitted_api_endpoints; @@ -255,9 +282,24 @@ }); $("#plugin_permitted_api_endpoints").html(endpointRows); } + + //Update subscribed events if available + let subscriptionsEvents = data.Spec.subscriptions_events; // this is a map of event_type to comment + if (subscriptionsEvents == null || Object.keys(subscriptionsEvents).length == 0) { + $("#plugin_subscriptions_events").html('No subscribed events'); + } else { + let eventRows = ''; + Object.keys(subscriptionsEvents).forEach(function(eventType) { + eventRows += ` + ${eventType} + ${subscriptionsEvents[eventType] || "No comment"} + `; + }); + $("#plugin_subscriptions_events").html(eventRows); + } + }); } - $(".advanceSettings").accordion(); function closeThisWrapper(){