Merge pull request #753 from AnthonyMichaelTDM/plugin-improvements

feat(plugins): Implement event system w/ POC events
This commit is contained in:
Anthony Rubick
2025-09-06 15:42:31 -05:00
committed by GitHub
24 changed files with 1714 additions and 14 deletions

View File

@@ -175,9 +175,15 @@ func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Reque
<head>
<title>API Call Example Plugin UI</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/main.css">
<link rel="stylesheet" href="/script/semantic/semantic.min.css">
<script src="/script/jquery-3.6.0.min.js"></script>
<script src="/script/semantic/semantic.min.js"></script>
<link rel="stylesheet" href="/main.css">
<style>
body {
background: none;
}
.response-block {
background-color: var(--theme_bg_primary);
border: 1px solid var(--theme_divider);
@@ -225,15 +231,19 @@ func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Reque
</style>
</head>
<body>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<div class="ui container">
<h1>Welcome to the API Call Example Plugin UI</h1>
<p>Plugin is running on port: ` + strconv.Itoa(config.Port) + `</p>
<div class="ui basic segment">
<h1 class="ui header">Welcome to the API Call Example Plugin UI</h1>
<p>Plugin is running on port: ` + strconv.Itoa(config.Port) + `</p>
</div>
<div class="ui divider"></div>
<h2>API Call Examples</h2>
<div class="response-block success">
<h3>✅ Allowed Endpoint (Valid API Key)</h3>
<p>Making a GET request to <code>/plugin/api/access/list</code> with a valid API key:</p>
@@ -265,7 +275,7 @@ func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Reque
` + RenderedUnaccessibleResponseHTML + `
</div>
</div>
</div>
</body>
</html>`
w.Write([]byte(html))

View File

@@ -0,0 +1,3 @@
module aroz.org/zoraxy/event-subscriber-example
go 1.24.5

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()
}

View File

@@ -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 = "<p>No events received yet<br>Try toggling a blacklist or something like that</p>"
} else {
EventLogMutex.Lock()
defer EventLogMutex.Unlock()
for _, event := range EventLog {
rawEventData, _ := json.Marshal(event)
eventLogHTML += "<div class='response-block'>"
eventLogHTML += "<h3>" + string(event.Name) + " at " + time.Unix(event.Timestamp, 0).Local().Format(time.RFC3339) + "</h3>"
eventLogHTML += "<div class='response-content'>"
eventLogHTML += "<p class='ui meta'>Event Data:</p>"
eventLogHTML += "<pre>" + string(rawEventData) + "</pre>"
eventLogHTML += "</div></div>"
}
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
html := `
<!DOCTYPE html>
<html>
<head>
<title>Event Log</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/script/semantic/semantic.min.css">
<script src="/script/jquery-3.6.0.min.js"></script>
<script src="/script/semantic/semantic.min.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/main.css">
<style>
body {
background: none;
}
.response-block {
background-color: var(--theme_bg_primary);
border: 1px solid var(--theme_divider);
border-radius: 8px;
padding: 20px;
margin: 5px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.3s ease;
}
.response-block:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.response-block h3 {
margin-top: 0;
color: var(--text_color);
border-bottom: 2px solid #007bff;
padding-bottom: 8px;
}
.response-content {
margin-top: 10px;
}
.response-content pre {
background-color: var(--theme_highlight);
border: 1px solid var(--theme_divider);
border-radius: 4px;
padding: 10px;
overflow: auto;
font-size: 12px;
line-height: 1.4;
height: fit-content;
max-height: 80vh;
resize: vertical;
box-sizing: border-box;
}
</style>
</head>
<body>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<div class="ui container">
<h1>Event Log</h1>
<div id="event-log" class="ui basic segment">` + eventLogHTML + `</div>
</body>
</html>
`
w.Write([]byte(html))
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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")
}
/*

View File

@@ -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()

View File

@@ -136,6 +136,11 @@
<small>The relative path of the web UI</small></td>
<td id="registered_ui_proxy_path"></td>
</tr>
<tr>
<td>Registered Subscribed Event Path</td>
<small>Path where subscribed events are sent</small>
<td id="registered_subscription_path">Not registered</td>
</tr>
</tbody>
</table>
<div class="ui divider"></div>
@@ -159,6 +164,23 @@
API keys are generated automatically by Zoraxy when a plugin with permitted API endpoints is enabled.
</p>
<div class="ui divider"></div>
<h4>Plugin IntroSpect Event Subscriptions</h4>
<p>The following events are subscribed to by this plugin and will be sent to the plugin's event subscription path:</p>
<table class="ui basic celled unstackable table">
<thead>
<tr>
<th>Event Type</th>
<th>Comment</th>
</tr>
</thead>
<!-- This tbody will be filled by JavaScript -->
<tbody id="plugin_subscriptions_events">
<tr>
<td colspan="2">No subscribed events</td>
</tr>
</tbody>
</table>
<div class="ui divider"></div>
</div>
</div>
</div>
@@ -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('<tr><td colspan="2">No subscribed events</td></tr>');
} else {
let eventRows = '';
Object.keys(subscriptionsEvents).forEach(function(eventType) {
eventRows += `<tr>
<td>${eventType}</td>
<td>${subscriptionsEvents[eventType] || "No comment"}</td>
</tr>`;
});
$("#plugin_subscriptions_events").html(eventRows);
}
});
}
$(".advanceSettings").accordion();
function closeThisWrapper(){