mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-10-14 14:49:35 +02:00
Merge pull request #753 from AnthonyMichaelTDM/plugin-improvements
feat(plugins): Implement event system w/ POC events
This commit is contained in:
@@ -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))
|
||||
|
3
example/plugins/event-subscriber-example/go.mod
Normal file
3
example/plugins/event-subscriber-example/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module aroz.org/zoraxy/event-subscriber-example
|
||||
|
||||
go 1.24.5
|
104
example/plugins/event-subscriber-example/main.go
Normal file
104
example/plugins/event-subscriber-example/main.go
Normal 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)
|
||||
}
|
@@ -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
|
@@ -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())
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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())
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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)
|
||||
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}
|
99
example/plugins/event-subscriber-example/ui.go
Normal file
99
example/plugins/event-subscriber-example/ui.go
Normal 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))
|
||||
}
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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"
|
||||
}
|
||||
|
||||
|
125
src/mod/eventsystem/event_system.go
Normal file
125
src/mod/eventsystem/event_system.go
Normal 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
|
||||
}
|
113
src/mod/eventsystem/event_system_test.go
Normal file
113
src/mod/eventsystem/event_system_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
83
src/mod/plugins/eventlistener.go
Normal file
83
src/mod/plugins/eventlistener.go
Normal 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
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
|
120
src/mod/plugins/zoraxy_plugin/events/events.go
Normal file
120
src/mod/plugins/zoraxy_plugin/events/events.go
Normal 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
|
||||
}
|
@@ -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")
|
||||
}
|
||||
|
||||
/*
|
||||
|
@@ -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()
|
||||
|
@@ -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(){
|
||||
|
Reference in New Issue
Block a user