76 Commits

Author SHA1 Message Date
Toby Chui
d5a980094b Merge pull request #804 from tobychui/v3.2.6
- Optimized SSO / forward auth feature 
- Added plugins API call and example
- Added experimental events system and plugin API
- Updated lego for DNS challenge
- Added experimental log rotate (default off)
- Updated log viewer UI
2025-09-16 20:32:59 +08:00
Toby Chui
3a2b38aac7 Update src/mod/info/logger/logger.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 19:56:06 +08:00
Toby Chui
2a6f4d52b2 Refactor mux setup and fix ReverseProxyInit typo
- Reorganized HTTP mux initialization to clarify plugin and webmin UI routing, replacing parentMux with entryMux.
- Fixed typo in ReverseProxyInit function name and updated its usage in main.go.
2025-09-13 23:46:38 +08:00
Toby Chui
6efab48d33 Merge pull request #820 from AnthonyMichaelTDM/issue819
put plugin API on separate mux not protected by CSRF
2025-09-13 23:16:27 +08:00
Anthony Rubick
e2c0fe3abf fix(example plugin): text color in dark mode 2025-09-13 02:47:55 -05:00
Anthony Rubick
1c26d60c8f fix issue #819
fixes issue #819 by putting the plugin API on a separate mux that is not wrapped in the CSRF middleware
2025-09-13 02:47:09 -05:00
Toby Chui
c745d82cf3 Merge pull request #813 from AnthonyMichaelTDM/plugin-to-plugin-comms
feat(plugin API): Plugin-to-plugin-comms
2025-09-13 14:37:31 +08:00
Anthony Rubick
36a48b5fe0 plugin2plugin messaging example
currently does not work due to CSRF, but should work when we figure out how to let plugins bypass csrf when talking to zoraxy
2025-09-12 21:37:47 -05:00
Anthony Rubick
c8e42dcf59 add a dummy event 2025-09-12 20:28:02 -05:00
Anthony Rubick
fa4700a114 feat(plugin api): add endpoint to facilitate plugin<->plugin comms via event system. 2025-09-08 18:04:16 -05:00
Anthony Rubick
f6c48ef793 Merge pull request #810 from AnthonyMichaelTDM/eventpayload-interface-improvements
feat(event system): Flesh out EventPayload interface
2025-09-08 17:54:52 -05:00
Anthony Rubick
da347cf1cb fix typo 2025-09-07 20:03:33 -05:00
Anthony Rubick
a36357dc04 take ownership of other plugin related files
specifically, the stuff around the API that's exposed to plugins
2025-09-07 19:53:06 -05:00
Anthony Rubick
c88fb0329b take ownership of the event system 2025-09-07 19:13:23 -05:00
Anthony Rubick
0debd0b907 perf(eventsystem): reduce duration locks are held
also added a test to ensure there is not a deadlock if a listener is marked as subscribed to an event, but not registered
2025-09-07 19:12:22 -05:00
Anthony Rubick
218c5aff40 test(eventsystem): additional tests for event emission 2025-09-07 18:04:10 -05:00
Anthony Rubick
c57fa39554 feat(eventsystem): plumbing for plugin<->plugin comms
The only thing left is to add an API endpoint for broadcasting
EventCustom events (other event types should not be emittible by
plugins, the use-case isn't there since plugins can already talk to
Zoraxy via the API).
Input to the endput should be a json-encoded `CustomEvent`
2025-09-07 18:03:48 -05:00
Anthony Rubick
2f98ecd0c6 fix(event-subscriber-example): remove event.go
the file was removed from zoraxy_plugin, but since `build_all.sh` works
by copying the module to the plugins this change wasn't propagated. This
fix removed the leftover file and updates `build_all.sh` to delete the
existing `zoraxy_plugin` module of the plugins before copying over the
updated code.
2025-09-07 17:10:02 -05:00
Anthony Rubick
73e4994ddc feat(eventpayload): add GetEventSource() to EventPayload interface
this design (as opposed to adding a Source field to the Event struct)
requires fewer changes to existing APIs while still supporting the two
primary cases for event sources:
1. an event that always has the same source can just return a hard-coded
string
2. an event that can come from multiple components (or from plugins) can
have a source field that gets returned by this function
2025-09-07 17:03:30 -05:00
Anthony Rubick
fd70b7d2dc feat(event): add UUID field to Event 2025-09-07 16:41:39 -05:00
Anthony Rubick
dbf4648646 Merge pull request #807 from AnthonyMichaelTDM/update-plugins
Update example plugins
2025-09-07 16:24:26 -05:00
Toby Chui
797ad92623 Merge pull request #805 from PassiveLemon/docker-326
feature: new container environment vars
2025-09-07 11:31:52 +08:00
Toby Chui
afdcc71358 Change ENABLELOG and ENABLELOGCOMPRESS defaults to true
Fixed invalid default value for new log flags
2025-09-07 11:20:52 +08:00
Anthony Rubick
46f0ae6896 feat: update all example plugins w/ current zoraxy_plugin
ran `build_all.sh`
2025-09-06 21:09:07 -05:00
Anthony Rubick
1c84a8f9cf fix: remove events import from zoraxy_plugin
The import, when the code is copied to develop a plugin, results an invalid path.

Fixing the path manually as a plugin developer is easy, but it shouldn't be necessary.
To fix that, the type is replaced with a string in zoraxy_plugin.IntroSpect and validation is added to lifecycle.go to ensure all subscribed events are valid.

A downside is that the list of validEventNames has to be updated whenever a new event is created, but this is mitigated by placing definitions of that list and the actual event names right next to each other.
2025-09-06 16:45:02 -05:00
Anthony Rubick
00013f3562 Merge pull request #753 from AnthonyMichaelTDM/plugin-improvements
feat(plugins): Implement event system w/ POC events
2025-09-06 15:42:31 -05:00
PassiveLemon
b743e0ea28 feature: new container environment vars 2025-09-06 13:58:35 -04:00
Toby Chui
7e6d60063e Merge branch 'main' into v3.2.6 2025-09-06 17:48:22 +08:00
Toby Chui
dbd795a158 Fixed #799
- Fixed UI bug in loopback options toggle
- Optimized plugin select ui
2025-09-06 13:36:00 +08:00
Toby Chui
df55157221 Merge pull request #789 from james-d-elliott/feat-sso-clear
feat(sso): clear settings
2025-09-06 13:22:49 +08:00
James Elliott
af0641c067 feat(sso): clear settings
This allows clearing the SSO options.
2025-09-06 14:53:13 +10:00
Toby Chui
66ff18c631 Merge pull request #788 from james-d-elliott/feat-forward-auith-original
feat(sso): forward auth body and alternate headers
2025-09-06 12:51:14 +08:00
Toby Chui
14bef4ef98 Added log start flags
- Added log rotate function (experimental)
- Added disable log function #802
- Added log compression for rotated file (experimental)
2025-09-06 00:44:54 +08:00
Anthony Rubick
22d2a0c6ca refactor: move event types into own submodule 2025-08-31 21:27:45 -05:00
Toby Chui
c3afdefe45 Added wip log rotate feature
- Added log rotate function interface
- Added darwin amd64 support in make file (Intel Macs)
- Added log summary and error API
2025-08-31 22:22:45 +08:00
Toby Chui
d9fd38260f Changed LogView tool type
- Changed logview representation form from snippet to new tab
2025-08-31 14:29:20 +08:00
Toby Chui
bf5ffa100c Update logview.html
Added more logview logic
2025-08-31 14:02:58 +08:00
Toby Chui
a175c258c9 Added support for MacOS WebSSH
- Added MacOS webssh feature
- Fixed bug on no proxy rule will cause tls option null exception
2025-08-31 12:35:11 +08:00
Toby Chui
7c3a1a9cfc Added wip new log viewer
- Added DNS challenge maintainer tag
- Added wip log viewer
2025-08-31 11:17:07 +08:00
Toby Chui
471e94c893 Merge pull request #795 from zen8841/v3.2.6
Update lego to v4.25.2
2025-08-30 14:54:10 +08:00
Zen Wen
19fd6057e0 Update lego to v4.25.2 2025-08-30 14:39:32 +08:00
Zen Wen
3ad8e5acb3 Remove acmedns file inside .gitignore 2025-08-30 14:38:31 +08:00
Zen Wen
dda922cb64 Update code-gen tools
Add dnshomede and myaddr to excluded dns provider list
2025-08-30 14:29:26 +08:00
Zen Wen
d4d0adb297 Update acme code-gen Script: update.sh
Change the pull branch to latest version tags
Avoid generating config that not include in the latest lego release
2025-08-30 14:28:10 +08:00
Zen Wen
e4950bbbe6 Add Shebang for tools Script 2025-08-30 14:26:07 +08:00
Zen Wen
e1fd28f595 Add execute permission for tools script 2025-08-30 14:24:09 +08:00
Anthony Rubick
f45d5f46b4 refactor(events): extract event system to own module and generalize subscriber handling 2025-08-24 18:46:03 -05:00
Toby Chui
cfd8f988fd Update CODEOWNERS 2025-08-25 06:58:07 +08:00
Toby Chui
e4a12b27a6 Update CODEOWNERS
Added forward-auth module owner
2025-08-25 06:56:44 +08:00
Toby Chui
abcd550261 Merge pull request #792 from AnthonyMichaelTDM/codeowners
add CODEOWNERS file
2025-08-25 06:54:54 +08:00
Anthony Rubick
e718ff1c72 add CODEOWNERS file 2025-08-24 17:34:00 -05:00
James Elliott
e477a40299 feat(sso): forward auth body and alternate headers
This implements a minor modification to the forward authz sso where the body can be copied to the auth server and the X-Original-* implementations can be used.
2025-08-23 21:53:25 +10:00
Anthony Rubick
d74ecb2444 fix: event deserialization 2025-08-19 19:09:50 -05:00
Anthony Rubick
fe2db92392 test: event deserialization 2025-08-19 19:09:50 -05:00
Anthony Rubick
ac3f12718a feat: add event subscription details to plugin info page 2025-08-19 19:09:49 -05:00
Anthony Rubick
dc12ee1716 feat: event subscription example plugin 2025-08-19 19:09:49 -05:00
Anthony Rubick
d6c907b13f feat: add function to assist parsing events 2025-08-19 19:09:49 -05:00
Anthony Rubick
9c99f6c734 feat(plugins): Implement event system w/ POC events
Implements the partially created event system with 3 events implemented as proof of concepts.

The 3 events are:
- `blacklistedIpBlocked`: emitted when a request from a blacklisted IP
- `accessRuleCreated`: emitted when a new access rule is created
- `blacklistToggled`: emitted when the blacklist is toggled for a given access rule

Why these events? Because these are the ones I forsee myself needing in the next version of the zoraxy_crowdsec_bouncer

Events are dispatched via a global event manager `plugins.EventSystem.Emit`
2025-08-19 19:08:46 -05:00
Toby Chui
c2866f27f8 Added #263
- Added IP / CIDR as Basic Auth exclusion rule
- Fixed side frame not closing when open proxy rule editor bug
2025-08-17 14:25:38 +08:00
Toby Chui
2daf3cd2cb Optimized plugin examples
- Fixed build script bug in plugin module copy logic
- Fixed plugin example typos
- Fixed the missing embeded web server handleFunc interface
2025-08-17 10:34:10 +08:00
Toby Chui
51145edae7 Merge pull request #772 from AnthonyMichaelTDM/issue-771
fix(issue 771): panics when rewriting headers for websockets, and strange issue with logging across a month boundary
2025-08-16 21:34:09 +08:00
Anthony Rubick
bd5d225a94 fix: out of bounds index when rewriting websocket headers 2025-08-01 02:12:50 -07:00
Toby Chui
4e32f31f0a Updated version number 2025-07-20 15:55:12 +08:00
Toby Chui
381184cd92 Merge pull request #746 from AnthonyMichaelTDM/plugin-improvements-api-keys
feat(plugins): Implement plugin API key management and authentication middleware
2025-07-20 15:45:14 +08:00
Anthony Rubick
ad2519d894 fix(example plugin): update PermittedAPIEndpoints 2025-07-19 22:50:42 -07:00
Anthony Rubick
40f915f7fb fix: update example plugin 2025-07-19 22:35:44 -07:00
Anthony Rubick
e3e31d9f22 feat: add the plugin accessible endpoints 2025-07-19 22:29:22 -07:00
Anthony Rubick
be5f631b9f refactor: reuse PluginAuthMiddleware as AuthAgent for plugin accessible endpoints 2025-07-19 22:29:02 -07:00
Anthony Rubick
f9e51bfd27 remove unused functions 2025-07-19 22:23:19 -07:00
Anthony Rubick
39b5da36d9 refactor: partial revert of dd93f9a2c4 2025-07-19 22:23:19 -07:00
Anthony Rubick
d187c32a8a feat(plugins): add an example api call to an accessible but unpermitted endpoint 2025-07-17 23:21:57 -07:00
Anthony Rubick
ed8f9b7337 fix(plugin-auth): check both endpoint and method 2025-07-17 23:18:40 -07:00
Anthony Rubick
46cfc02493 feat(webui/plugininfo): Add section for permitted API endpoints 2025-07-17 22:47:57 -07:00
Anthony Rubick
2d43890fcf feat: Add .gitignore for Docker to exclude example and src directories 2025-07-17 22:20:09 -07:00
Anthony Rubick
5a38c1d407 feat(plugins): Add API Call Example Plugin 2025-07-17 22:20:09 -07:00
Anthony Rubick
dd93f9a2c4 feat(plugins): Implement plugin API key management and authentication middleware
The purpose of this is to allow plugins to access certain internal APIs via

- Added PluginAPIKey and APIKeyManager for managing API keys associated with plugins.
- Introduced PluginAuthMiddleware to handle API key validation for plugin requests.
- Updated RouterDef to support plugin accessible endpoints with authentication.
- Modified various API registration functions to include plugin accessibility checks.
- Enhanced plugin lifecycle management to generate and revoke API keys as needed.
- Updated plugin specifications to include permitted API endpoints for access control.
2025-07-17 22:20:09 -07:00
128 changed files with 11220 additions and 5738 deletions

1
.gitignore vendored
View File

@@ -62,3 +62,4 @@ www/html/index.html
/src/dist
/src/plugins
.DS_Store

17
CODEOWNERS Normal file
View File

@@ -0,0 +1,17 @@
# tobycui is the default owner for all files in this repository
* @tobychui
# PassiveLemon is the docker maintainer
/docker @PassiveLemon
# james-d-elliott is the community maintainer for forward-auth related functions
# /src/mod/auth/sso/forward @james-d-elliott
# jemmy1794 maintains the stream proxy module
/src/mod/streamproxy @jemmy1794
# AnthonyMichaelTDM maintains the plugin and event systems
/src/mod/plugins @AnthonyMichaelTDM
/example/plugins @AnthonyMichaelTDM
/src/**/plugin_*.go @AnthonyMichaelTDM
/src/mod/eventsystem @AnthonyMichaelTDM

View File

@@ -198,6 +198,12 @@ Some section of Zoraxy are contributed by our amazing community and if you have
- (Legacy) Authentik Support added by [@JokerQyou](https://github.com/JokerQyou)
- ACME
- ACME integration (Looking for maintainer)
- DNS Challenge by [@zen8841](https://github.com/zen8841)
- Docker Container List by [@eyerrock](https://github.com/eyerrock)
- Stream Proxy [@jemmy1794](https://github.com/jemmy1794)

2
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
example/
src/

View File

@@ -57,7 +57,10 @@ ENV CFGUPGRADE="true"
ENV DB="auto"
ENV DOCKER="true"
ENV EARLYRENEW="30"
ENV ENABLELOG="true"
ENV ENABLELOGCOMPRESS="true"
ENV FASTGEOIP="false"
ENV LOGROTATE="0"
ENV MDNS="true"
ENV MDNSNAME="''"
ENV NOAUTH="false"

View File

@@ -87,7 +87,10 @@ Variables are the same as those in [Start Parameters](https://github.com/tobychu
| `DB` | `auto` (String) | Database backend to use (leveldb, boltdb, auto) Note that fsdb will be used on unsupported platforms like RISCV (default "auto"). |
| `DOCKER` | `true` (Boolean) | Run Zoraxy in docker compatibility mode. |
| `EARLYRENEW` | `30` (Integer) | Number of days to early renew a soon expiring certificate. |
| `ENABLELOG` | `true` (Boolean) | Enable system wide logging, set to false for writing log to STDOUT only. |
| `ENABLELOGCOMPRESS` | `true` (Boolean) | Enable log compression for rotated log files. |
| `FASTGEOIP` | `false` (Boolean) | Enable high speed geoip lookup, require 1GB extra memory (Not recommend for low end devices). |
| `LOGROTATE` | `0` (Integer) | Enable log rotation and set the maximum log file size in KB (e.g. 25 for 25KB), set to 0 for disable. |
| `MDNS` | `true` (Boolean) | Enable mDNS scanner and transponder. |
| `MDNSNAME` | `''` (String) | mDNS name, leave empty to use default (zoraxy_{node-uuid}.local). |
| `NOAUTH` | `false` (Boolean) | Disable authentication for management interface. |

View File

@@ -87,22 +87,25 @@ def start_zoraxy():
zoraxy_args = [
"zoraxy",
f"-autorenew={getenv('AUTORENEW', '86400')}",
f"-cfgupgrade={getenv('CFGUPGRADE', 'true')}",
f"-db={getenv('DB', 'auto')}",
f"-docker={getenv('DOCKER', 'true')}",
f"-earlyrenew={getenv('EARLYRENEW', '30')}",
f"-fastgeoip={getenv('FASTGEOIP', 'false')}",
f"-mdns={getenv('MDNS', 'true')}",
f"-mdnsname={getenv('MDNSNAME', "''")}",
f"-noauth={getenv('NOAUTH', 'false')}",
f"-plugin={getenv('PLUGIN', '/opt/zoraxy/plugin/')}",
f"-port=:{getenv('PORT', '8000')}",
f"-sshlb={getenv('SSHLB', 'false')}",
f"-update_geoip={getenv('UPDATE_GEOIP', 'false')}",
f"-version={getenv('VERSION', 'false')}",
f"-webfm={getenv('WEBFM', 'true')}",
f"-webroot={getenv('WEBROOT', './www')}",
f"-autorenew={ getenv('AUTORENEW', '86400') }",
f"-cfgupgrade={ getenv('CFGUPGRADE', 'true') }",
f"-db={ getenv('DB', 'auto') }",
f"-docker={ getenv('DOCKER', 'true') }",
f"-earlyrenew={ getenv('EARLYRENEW', '30') }",
f"-enablelog={ getenv('ENABLELOG', 'true') }",
f"-enablelogcompress={ getenv('ENABLELOGCOMPRESS', 'true') }",
f"-fastgeoip={ getenv('FASTGEOIP', 'false') }",
f"-logrotate={ getenv('LOGROTATE', '0') }",
f"-mdns={ getenv('MDNS', 'true') }",
f"-mdnsname={ getenv('MDNSNAME', "''") }",
f"-noauth={ getenv('NOAUTH', 'false') }",
f"-plugin={ getenv('PLUGIN', '/opt/zoraxy/plugin/') }",
f"-port=:{ getenv('PORT', '8000') }",
f"-sshlb={ getenv('SSHLB', 'false') }",
f"-update_geoip={ getenv('UPDATE_GEOIP', 'false') }",
f"-version={ getenv('VERSION', 'false') }",
f"-webfm={ getenv('WEBFM', 'true') }",
f"-webroot={ getenv('WEBROOT', './www') }",
]
zoraxy_proc = popen(zoraxy_args)

10
example/plugins/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
api-call-example/api-call-example
debugger/debugger
dynamic-capture-example/dynamic-capture-example
event-subscriber-example/event-subscriber-example
helloworld/helloworld
plugin2plugin-comms-peer1/plugin2plugin-comms-peer1
plugin2plugin-comms-peer2/plugin2plugin-comms-peer2
restful-example/restful-example
static-capture-example/static-capture-example
upnp/upnp

10
example/plugins/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Example Plugins
This directory contains example plugins that demonstrate how to create and use plugins with the main application. Each plugin is designed to showcase different features and capabilities of the plugin system.
## Some Note-Worthy Examples
- **api-call-example**: Demonstrates how plugins can make API calls to zoraxy
- **event-subscriber-example**: Shows how to subscribe to and handle events from zoraxy within the application
- **plugin2plugin-comms-peer1**: Illustrates communication between two plugins via the event system, where this plugin acts as the first peer
- **plugin2plugin-comms-peer2**: Similar to the above, but this plugin acts as the second peer in the communication

View File

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

View File

@@ -0,0 +1,54 @@
package main
import (
"fmt"
"net/http"
plugin "aroz.org/zoraxy/api-call-example/mod/zoraxy_plugin"
)
const (
PLUGIN_ID = "org.aroz.zoraxy.api_call_example"
UI_PATH = "/ui"
)
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: "API Call Example Plugin",
Author: "Anthony Rubick",
AuthorContact: "",
Description: "An example plugin for making API calls",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
UIPath: UI_PATH,
/* API Access Control */
PermittedAPIEndpoints: []plugin.PermittedAPIEndpoint{
{
Method: http.MethodGet,
Endpoint: "/plugin/api/access/list",
Reason: "Used to display all configured Access Rules",
},
},
})
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)
})
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,174 @@
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)
}()
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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[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
/* 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,282 @@
package main
import (
"fmt"
"html"
"net/http"
"net/http/httputil"
"strconv"
plugin "aroz.org/zoraxy/api-call-example/mod/zoraxy_plugin"
)
func allowedEndpoint(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to the permitted endpoint
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/api/access/list", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Make sure to set the Authorization header
req.Header.Set("Authorization", "Bearer "+cfg.APIKey) // Use the API key from the runtime config
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
// Check if the response status is OK
if resp.StatusCode != http.StatusOK {
return string(respDump), fmt.Errorf("received non-OK response status %d", resp.StatusCode)
}
return string(respDump), nil
}
func allowedEndpointInvalidKey(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to the permitted endpoint with an invalid key
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/api/access/list", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Use an invalid API key
req.Header.Set("Authorization", "Bearer invalid-key")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
return string(respDump), nil
}
func unaccessibleEndpoint(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to an endpoint that is not permitted
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/api/acme/listExpiredDomains", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Use the API key from the runtime config
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
return string(respDump), nil
}
func unpermittedEndpoint(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to an endpoint that is plugin-accessible but is not permitted
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/api/proxy/list", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Use the API key from the runtime config
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
return string(respDump), nil
}
func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Request) {
// make several types of API calls to demonstrate the plugin functionality
accessList, err := allowedEndpoint(config)
var RenderedAccessListHTML string
if err != nil {
if accessList != "" {
RenderedAccessListHTML = fmt.Sprintf("<p>Error fetching access list: %v</p><pre>%s</pre>", err, html.EscapeString(accessList))
} else {
RenderedAccessListHTML = fmt.Sprintf("<p>Error fetching access list: %v</p>", err)
}
} else {
// Render the access list as HTML
RenderedAccessListHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(accessList))
}
// Make an API call with an invalid key
invalidKeyResponse, err := allowedEndpointInvalidKey(config)
var RenderedInvalidKeyResponseHTML string
if err != nil {
RenderedInvalidKeyResponseHTML = fmt.Sprintf("<p>Error with invalid key: %v</p>", err)
} else {
// Render the invalid key response as HTML
RenderedInvalidKeyResponseHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(invalidKeyResponse))
}
// Make an API call to an endpoint that is not plugin-accessible
unaccessibleResponse, err := unaccessibleEndpoint(config)
var RenderedUnaccessibleResponseHTML string
if err != nil {
RenderedUnaccessibleResponseHTML = fmt.Sprintf("<p>Error with unaccessible endpoint: %v</p>", err)
} else {
// Render the unaccessible response as HTML
RenderedUnaccessibleResponseHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(unaccessibleResponse))
}
// Make an API call to an endpoint that is plugin-accessible but is not permitted
unpermittedResponse, err := unpermittedEndpoint(config)
var RenderedUnpermittedResponseHTML string
if err != nil {
RenderedUnpermittedResponseHTML = fmt.Sprintf("<p>Error with unpermitted endpoint: %v</p>", err)
} else {
// Render the unpermitted response as HTML
RenderedUnpermittedResponseHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(unpermittedResponse))
}
// Render the UI for the plugin
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
html := `
<!DOCTYPE html>
<html>
<head>
<title>API Call Example Plugin UI</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>
<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: 15px 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-block.success {
border-left: 4px solid #28a745;
}
.response-block.error {
border-left: 4px solid #dc3545;
}
.response-block.warning {
border-left: 4px solid #ffc107;
}
.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: 200px;
max-height: 80vh;
min-height: 100px;
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">
<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>
<div class="response-content">
` + RenderedAccessListHTML + `
</div>
</div>
<div class="response-block warning">
<h3>⚠️ Invalid API Key</h3>
<p>Making a GET request to <code>/plugin/api/access/list</code> with an invalid API key:</p>
<div class="response-content">
` + RenderedInvalidKeyResponseHTML + `
</div>
</div>
<div class="response-block warning">
<h3>⚠️ Unpermitted Endpoint</h3>
<p>Making a GET request to <code>/plugin/api/proxy/list</code> (not a permitted endpoint):</p>
<div class="response-content">
` + RenderedUnpermittedResponseHTML + `
</div>
</div>
<div class="response-block error">
<h3>❌ Disallowed Endpoint</h3>
<p>Making a GET request to <code>/api/acme/listExpiredDomains</code> (not a plugin-accessible endpoint):</p>
<div class="response-content">
` + RenderedUnaccessibleResponseHTML + `
</div>
</div>
</div>
</body>
</html>`
w.Write([]byte(html))
}

View File

@@ -4,7 +4,12 @@
echo "Copying zoraxy_plugin to all mods"
for dir in ./*; do
if [ -d "$dir" ]; then
cp -r ../mod/plugins/zoraxy_plugin "$dir/mod"
# remove existing zoraxy_plugin module, if it exists
if [ -d "${dir}/mod/zoraxy_plugin" ]; then
rm -r $dir/mod/zoraxy_plugin
fi
# copy over updated module
cp -r ../../src/mod/plugins/zoraxy_plugin "$dir/mod"
fi
done

View File

@@ -86,7 +86,7 @@ func main() {
if strings.HasPrefix(dsfr.RequestURI, "/test_") {
reqUUID := dsfr.GetRequestUUID()
fmt.Println("Accepting request with UUID: " + reqUUID)
return plugin.SniffResultAccpet
return plugin.SniffResultAccept
}
return plugin.SniffResultSkip

View File

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -145,6 +145,24 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
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
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* 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, see Zoraxy documentation for more details
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
/* 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
}
/*
@@ -126,8 +135,10 @@ 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
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
}
@@ -156,12 +167,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

@@ -77,8 +77,8 @@ func main() {
fmt.Println("ProtoMajor:", dsfr.ProtoMajor)
fmt.Println("ProtoMinor:", dsfr.ProtoMinor)
// We want to handle this request, reply with aSniffResultAccept
return plugin.SniffResultAccpet
// We want to handle this request, reply with a SniffResultAccept
return plugin.SniffResultAccept
}
// If the request URI does not match, we skip this request

View File

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
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
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* 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, see Zoraxy documentation for more details
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
/* 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
}
/*
@@ -126,8 +135,10 @@ 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
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
}
@@ -156,12 +167,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

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

View File

@@ -0,0 +1,106 @@
package main
import (
"bytes"
"fmt"
"net/http"
"sync"
plugin "aroz.org/zoraxy/event-subscriber-example/mod/zoraxy_plugin"
"aroz.org/zoraxy/event-subscriber-example/mod/zoraxy_plugin/events"
)
const (
PLUGIN_ID = "org.aroz.zoraxy.event_subscriber_example"
UI_PATH = "/ui"
EVENT_PATH = "/notifyme/"
)
var (
EventLog = make([]events.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[string]string{
// for this example, we will subscribe to all events that exist at time of writing
string(events.EventBlacklistedIPBlocked): "This event is triggered when a blacklisted IP is blocked",
string(events.EventBlacklistToggled): "This event is triggered when the blacklist is toggled for an access rule",
string(events.EventAccessRuleCreated): "This event is triggered when a new access ruleset is created",
string(events.EventCustom): "This event is a custom event that can be emitted by any plugin, we subscribe to it to demonstrate a \"monitor\" plugin that can see all custom events emitted by other plugins",
},
})
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 events.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 := events.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,174 @@
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)
}()
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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[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
/* 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

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -145,6 +145,24 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
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
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* 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, see Zoraxy documentation for more details
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
/* 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
}
/*
@@ -126,8 +135,10 @@ 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
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
}
@@ -156,12 +167,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

@@ -0,0 +1,224 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
plugin "aroz.org/zoraxy/plugins/plugin2plugin-comms-peer1/mod/zoraxy_plugin"
"aroz.org/zoraxy/plugins/plugin2plugin-comms-peer1/mod/zoraxy_plugin/events"
)
type Message struct {
Message string `json:"message"`
Sent bool `json:"sent"`
}
var (
// map of connected SSE clients
messageHistory []Message = make([]Message, 0)
messageHistoryMu = &sync.Mutex{}
clients = make(map[chan *events.CustomEvent]struct{})
clientsMu = &sync.Mutex{}
)
func sendMessageToPeer(config *plugin.ConfigureSpec, message string) error {
// build the request payload
event := events.CustomEvent{
SourcePlugin: PLUGIN_ID,
Recipients: []string{PEER_ID},
Payload: map[string]any{"message": message},
}
// Make an API call to the peer plugin's endpoint
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/event/emit", config.ZoraxyPort)
payload := new(bytes.Buffer)
if err := json.NewEncoder(payload).Encode(event); err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, apiURL, payload)
if err != nil {
return err
}
// Make sure to set the Authorization header
req.Header.Set("Authorization", "Bearer "+config.APIKey) // Use the API key from the runtime config
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
response_body, _ := io.ReadAll(resp.Body)
fmt.Printf("Response Body: %s\n", string(response_body))
return fmt.Errorf("failed to call the zoraxy API: %s, %v", resp.Status, string(response_body))
}
return nil
}
func handleSendMessage(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the message body
var body struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Failed to parse JSON body", http.StatusBadRequest)
return
}
message := body.Message
if message == "" {
http.Error(w, "Message cannot be empty", http.StatusBadRequest)
return
}
// send the message to the peer plugin
err := sendMessageToPeer(config, message)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to send message to peer: %v", err), http.StatusInternalServerError)
return
}
// Log the sent message
messageHistoryMu.Lock()
messageHistory = append(messageHistory, Message{Message: message, Sent: true})
messageHistoryMu.Unlock()
w.WriteHeader(http.StatusOK)
w.Write([]byte("Message sent to peer successfully"))
}
func handleFetchMessageHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
messageHistoryMu.Lock()
historyCopy := make([]Message, len(messageHistory))
copy(historyCopy, messageHistory)
messageHistoryMu.Unlock()
resp := struct {
Messages []Message `json:"messages"`
}{
Messages: historyCopy,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func handleReceivedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var event events.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 := events.ParseEvent(buffer.Bytes(), &event); err != nil {
http.Error(w, fmt.Sprintf("Failed to parse event: %v", err), http.StatusBadRequest)
return
}
switch event.Name {
case events.EventCustom:
// downcast event.Data to CustomEvent
customData, ok := event.Data.(*events.CustomEvent)
if !ok {
http.Error(w, "Invalid event data for CustomEvent", http.StatusBadRequest)
return
}
// Log the received message
messageHistoryMu.Lock()
if msg, exists := customData.Payload["message"].(string); exists {
messageHistory = append(messageHistory, Message{Message: msg, Sent: false})
}
messageHistoryMu.Unlock()
// Broadcast to all connected SSE clients
broadcastMessage(customData)
// Respond to the sender
w.WriteHeader(http.StatusOK)
w.Write([]byte("Event received successfully"))
// For demonstration, print the message to the console
fmt.Printf("Received message from plugin %s: %v\n", customData.SourcePlugin, customData.Payload["message"])
default:
http.Error(w, fmt.Sprintf("Unhandled event type: %s", event.Name), http.StatusBadRequest)
return
}
}
// SSE handler
func handleSSE(w http.ResponseWriter, r *http.Request) {
fmt.Println("SSE connection established")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
eventChan := make(chan *events.CustomEvent)
clientsMu.Lock()
clients[eventChan] = struct{}{}
clientsMu.Unlock()
defer func() {
clientsMu.Lock()
delete(clients, eventChan)
clientsMu.Unlock()
close(eventChan)
}()
// Send events as they arrive
for event := range eventChan {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
// Broadcast to all clients
func broadcastMessage(message *events.CustomEvent) {
clientsMu.Lock()
defer clientsMu.Unlock()
for ch := range clients {
select {
case ch <- message:
default:
// If the client is not listening, skip
}
}
}

View File

@@ -0,0 +1,3 @@
module aroz.org/zoraxy/plugins/plugin2plugin-comms-peer1
go 1.24.5

View File

@@ -0,0 +1,94 @@
package main
import (
"embed"
"fmt"
"net/http"
"strconv"
plugin "aroz.org/zoraxy/plugins/plugin2plugin-comms-peer1/mod/zoraxy_plugin"
)
// Notes:
// This plugin handles updating the UI with new messages received from the peer plugin via SSE, other option you
// could use are WebSockets or polling the server at intervals
const (
PLUGIN_ID = "org.aroz.zoraxy.plugin2plugin_comms_peer1"
PEER_ID = "org.aroz.zoraxy.plugin2plugin_comms_peer2"
UI_PATH = "/ui"
SUBSCRIPTION_PATH = "/notifyme"
WEB_ROOT = "/www"
)
//go:embed www/*
var content embed.FS
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: "Plugin2Plugin Comms Peer 1",
Author: "Anthony Rubick",
AuthorContact: "",
Description: "An example plugin for demonstrating plugin to plugin communications - Peer 1",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
UIPath: UI_PATH,
/* API Access Control */
PermittedAPIEndpoints: []plugin.PermittedAPIEndpoint{
{
Method: http.MethodPost,
Endpoint: "/plugin/event/emit",
Reason: "Used to send events to the peer plugin",
},
},
/* Subscriptions Settings */
SubscriptionPath: SUBSCRIPTION_PATH,
SubscriptionsEvents: map[string]string{
"dummy": "A dummy event to satisfy the requirement of having at least one event",
},
})
if err != nil {
fmt.Printf("Error serving introspect: %v\n", err)
return
}
// Start the HTTP server
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
// for debugging, use the following line instead
// embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, "."+ WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
// Do cleanup here if needed
fmt.Println("Plugin Exited")
}, nil)
// Serve the API
RegisterAPIs(runtimeCfg)
// Serve the web page in the www folder
http.Handle(UI_PATH+"/", embedWebRouter.Handler())
http.HandleFunc(SUBSCRIPTION_PATH+"/", handleReceivedEvent)
fmt.Println("Plugin2Plugin Comms Peer 1 started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port))
err = http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil)
if err != nil {
panic(err)
}
}
func RegisterAPIs(cfg *plugin.ConfigureSpec) {
// Add API handlers here
http.HandleFunc(UI_PATH+"/api/send_message", func(w http.ResponseWriter, r *http.Request) {
handleSendMessage(cfg, w, r)
})
http.HandleFunc(UI_PATH+"/api/events", handleSSE)
http.HandleFunc(UI_PATH+"/api/message_history", handleFetchMessageHistory)
}

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,174 @@
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)
}()
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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[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
/* 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,152 @@
<!DOCTYPE html>
<html>
<head>
<title>Plugin2Plugin Comms</title>
<meta charset="UTF-8">
<!-- CSRF token, if your plugin need to make POST request to backend -->
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
<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>
<script src="/script/utils.js"></script>
<link rel="stylesheet" href="/main.css">
<style>
body {
background: none;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.sent-message {
background-color: var(--theme_bg_primary);
border-left: 5px solid #155724;
animation: fadeIn 0.5s;
}
.received-message {
background-color: var(--theme_bg_secondary);
border-left: 5px solid #004085;
animation: fadeIn 0.5s;
}
</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">
<!-- Toast container -->
<div class="toast-container"></div>
<script>
// Function to show toast message
function showToast(message, type = 'success') {
const toast = $('<div class="ui message ' + type + '" style="opacity: 0;">' + message + '</div>');
$('.toast-container').append(toast);
toast.animate({opacity: 1}, 300);
// Auto-hide after 3 seconds
setTimeout(function() {
toast.animate({opacity: 0}, 300, function() {
toast.remove();
});
}, 3000);
}
</script>
<div class="ui basic segment">
<h1 class="ui header">Welcome to the Plugin2Plugin Comms Peer 1 UI</h1>
</div>
<div class="ui divider"></div>
<!-- Message Form -->
<div class="ui segment">
<h2 class="ui header">Send Message to Peer Plugin</h2>
<div class="ui form" id="messageForm">
<div class="field">
<label for="messageInput">Message:</label>
<input type="text" id="messageInput" name="message" placeholder="Enter your message" required>
</div>
<button class="ui primary button" id="sendMessageButton">Send Message</button>
</div>
</div>
<script>
// Handle form submission
$('#sendMessageButton').click(function(event) {
event.preventDefault();
const message = $('#messageInput').val();
$.cjax({
url: './api/send_message',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ message: message }),
success: function(response) {
showToast('Message sent!');
// Log the sent message
const sentMessage = $('<div class="item sent-message"><div class="content"><div class="header">Sent:</div><div class="description">' + message + '</div></div></div>');
$('#messageLog').prepend(sentMessage);
$('#messageInput').val(''); // Clear input field
},
error: function(xhr, status, error) {
showToast('Error sending message!', 'error');
console.error('Error:', error);
}
});
});
</script>
<div class="ui divider"></div>
<!-- Message Log -->
<div class="ui segment">
<h2 class="ui header">Messages</h2>
<div id="messageLog" class="ui relaxed divided list" style="max-height: 300px; overflow-y: auto;">
<!-- Messages will be appended here -->
</div>
</div>
<script>
// Set up EventSource to listen for incoming messages
const eventSource = new EventSource('./api/events');
eventSource.onmessage = function(e) {
const event = JSON.parse(e.data);
if (event && event.payload && event.payload.message) {
const receivedMessage = $('<div class="item received-message"><div class="content"><div class="header">Received:</div><div class="description">' + event.payload.message + '</div></div></div>');
$('#messageLog').prepend(receivedMessage);
}
showToast('New message received!');
};
eventSource.onerror = function(err) {
console.error('EventSource failed:', err);
eventSource.close();
};
// Clean up EventSource on page unload
window.addEventListener('beforeunload', function() {
eventSource.close();
});
// Fetch and display message history on page load
$(document).ready(function() {
$.cjax({
url: './api/message_history',
type: 'GET',
success: function(response) {
if (response && response.messages) {
response.messages.forEach(function(msg) {
const messageClass = msg.sent ? 'sent-message' : 'received-message';
const messageItem = $('<div class="item ' + messageClass + '"><div class="content"><div class="header">' + (msg.sent ? 'Sent:' : 'Received:') + '</div><div class="description">' + msg.message + '</div></div></div>');
$('#messageLog').append(messageItem);
});
}
},
error: function(xhr, status, error) {
console.error('Error fetching message history:', error);
}
});
});
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,224 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
plugin "aroz.org/zoraxy/plugins/plugin2plugin-comms-peer2/mod/zoraxy_plugin"
"aroz.org/zoraxy/plugins/plugin2plugin-comms-peer2/mod/zoraxy_plugin/events"
)
type Message struct {
Message string `json:"message"`
Sent bool `json:"sent"`
}
var (
// map of connected SSE clients
messageHistory []Message = make([]Message, 0)
messageHistoryMu = &sync.Mutex{}
clients = make(map[chan *events.CustomEvent]struct{})
clientsMu = &sync.Mutex{}
)
func sendMessageToPeer(config *plugin.ConfigureSpec, message string) error {
// build the request payload
event := events.CustomEvent{
SourcePlugin: PLUGIN_ID,
Recipients: []string{PEER_ID},
Payload: map[string]any{"message": message},
}
// Make an API call to the peer plugin's endpoint
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/event/emit", config.ZoraxyPort)
payload := new(bytes.Buffer)
if err := json.NewEncoder(payload).Encode(event); err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, apiURL, payload)
if err != nil {
return err
}
// Make sure to set the Authorization header
req.Header.Set("Authorization", "Bearer "+config.APIKey) // Use the API key from the runtime config
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
response_body, _ := io.ReadAll(resp.Body)
fmt.Printf("Response Body: %s\n", string(response_body))
return fmt.Errorf("failed to call the zoraxy API: %s, %v", resp.Status, string(response_body))
}
return nil
}
func handleSendMessage(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the message body
var body struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Failed to parse JSON body", http.StatusBadRequest)
return
}
message := body.Message
if message == "" {
http.Error(w, "Message cannot be empty", http.StatusBadRequest)
return
}
// send the message to the peer plugin
err := sendMessageToPeer(config, message)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to send message to peer: %v", err), http.StatusInternalServerError)
return
}
// Log the sent message
messageHistoryMu.Lock()
messageHistory = append(messageHistory, Message{Message: message, Sent: true})
messageHistoryMu.Unlock()
w.WriteHeader(http.StatusOK)
w.Write([]byte("Message sent to peer successfully"))
}
func handleFetchMessageHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
messageHistoryMu.Lock()
historyCopy := make([]Message, len(messageHistory))
copy(historyCopy, messageHistory)
messageHistoryMu.Unlock()
resp := struct {
Messages []Message `json:"messages"`
}{
Messages: historyCopy,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func handleReceivedEvent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var event events.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 := events.ParseEvent(buffer.Bytes(), &event); err != nil {
http.Error(w, fmt.Sprintf("Failed to parse event: %v", err), http.StatusBadRequest)
return
}
switch event.Name {
case events.EventCustom:
// downcast event.Data to CustomEvent
customData, ok := event.Data.(*events.CustomEvent)
if !ok {
http.Error(w, "Invalid event data for CustomEvent", http.StatusBadRequest)
return
}
// Log the received message
messageHistoryMu.Lock()
if msg, exists := customData.Payload["message"].(string); exists {
messageHistory = append(messageHistory, Message{Message: msg, Sent: false})
}
messageHistoryMu.Unlock()
// Broadcast to all connected SSE clients
broadcastMessage(customData)
// Respond to the sender
w.WriteHeader(http.StatusOK)
w.Write([]byte("Event received successfully"))
// For demonstration, print the message to the console
fmt.Printf("Received message from plugin %s: %v\n", customData.SourcePlugin, customData.Payload["message"])
default:
http.Error(w, fmt.Sprintf("Unhandled event type: %s", event.Name), http.StatusBadRequest)
return
}
}
// SSE handler
func handleSSE(w http.ResponseWriter, r *http.Request) {
fmt.Println("SSE connection established")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
eventChan := make(chan *events.CustomEvent)
clientsMu.Lock()
clients[eventChan] = struct{}{}
clientsMu.Unlock()
defer func() {
clientsMu.Lock()
delete(clients, eventChan)
clientsMu.Unlock()
close(eventChan)
}()
// Send events as they arrive
for event := range eventChan {
data, _ := json.Marshal(event)
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
}
}
// Broadcast to all clients
func broadcastMessage(message *events.CustomEvent) {
clientsMu.Lock()
defer clientsMu.Unlock()
for ch := range clients {
select {
case ch <- message:
default:
// If the client is not listening, skip
}
}
}

View File

@@ -0,0 +1,3 @@
module aroz.org/zoraxy/plugins/plugin2plugin-comms-peer2
go 1.24.5

View File

@@ -0,0 +1,94 @@
package main
import (
"embed"
"fmt"
"net/http"
"strconv"
plugin "aroz.org/zoraxy/plugins/plugin2plugin-comms-peer2/mod/zoraxy_plugin"
)
// Notes:
// This plugin handles updating the UI with new messages received from the peer plugin via SSE, other option you
// could use are WebSockets or polling the server at intervals
const (
PLUGIN_ID = "org.aroz.zoraxy.plugin2plugin_comms_peer2"
PEER_ID = "org.aroz.zoraxy.plugin2plugin_comms_peer1"
UI_PATH = "/ui"
SUBSCRIPTION_PATH = "/notifyme"
WEB_ROOT = "/www"
)
//go:embed www/*
var content embed.FS
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: "Plugin2Plugin Comms Peer 2",
Author: "Anthony Rubick",
AuthorContact: "",
Description: "An example plugin for demonstrating plugin to plugin communications - Peer 2",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
UIPath: UI_PATH,
/* API Access Control */
PermittedAPIEndpoints: []plugin.PermittedAPIEndpoint{
{
Method: http.MethodPost,
Endpoint: "/plugin/event/emit",
Reason: "Used to send events to the peer plugin",
},
},
/* Subscriptions Settings */
SubscriptionPath: SUBSCRIPTION_PATH,
SubscriptionsEvents: map[string]string{
"dummy": "A dummy event to satisfy the requirement of having at least one event",
},
})
if err != nil {
fmt.Printf("Error serving introspect: %v\n", err)
return
}
// Start the HTTP server
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
// for debugging, use the following line instead
// embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, "."+ WEB_ROOT, UI_PATH)
embedWebRouter.RegisterTerminateHandler(func() {
// Do cleanup here if needed
fmt.Println("Plugin Exited")
}, nil)
// Serve the API
RegisterAPIs(runtimeCfg)
// Serve the web page in the www folder
http.Handle(UI_PATH+"/", embedWebRouter.Handler())
http.HandleFunc(SUBSCRIPTION_PATH+"/", handleReceivedEvent)
fmt.Println("Plugin2Plugin Comms Peer 2 started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port))
err = http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil)
if err != nil {
panic(err)
}
}
func RegisterAPIs(cfg *plugin.ConfigureSpec) {
// Add API handlers here
http.HandleFunc(UI_PATH+"/api/send_message", func(w http.ResponseWriter, r *http.Request) {
handleSendMessage(cfg, w, r)
})
http.HandleFunc(UI_PATH+"/api/events", handleSSE)
http.HandleFunc(UI_PATH+"/api/message_history", handleFetchMessageHistory)
}

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,174 @@
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)
}()
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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[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
/* 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,152 @@
<!DOCTYPE html>
<html>
<head>
<title>Plugin2Plugin Comms</title>
<meta charset="UTF-8">
<!-- CSRF token, if your plugin need to make POST request to backend -->
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
<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>
<script src="/script/utils.js"></script>
<link rel="stylesheet" href="/main.css">
<style>
body {
background: none;
}
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.sent-message {
background-color: var(--theme_bg_primary);
border-left: 5px solid #155724;
animation: fadeIn 0.5s;
}
.received-message {
background-color: var(--theme_bg_secondary);
border-left: 5px solid #004085;
animation: fadeIn 0.5s;
}
</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">
<!-- Toast container -->
<div class="toast-container"></div>
<script>
// Function to show toast message
function showToast(message, type = 'success') {
const toast = $('<div class="ui message ' + type + '" style="opacity: 0;">' + message + '</div>');
$('.toast-container').append(toast);
toast.animate({opacity: 1}, 300);
// Auto-hide after 3 seconds
setTimeout(function() {
toast.animate({opacity: 0}, 300, function() {
toast.remove();
});
}, 3000);
}
</script>
<div class="ui basic segment">
<h1 class="ui header">Welcome to the Plugin2Plugin Comms Peer 2 UI</h1>
</div>
<div class="ui divider"></div>
<!-- Message Form -->
<div class="ui segment">
<h2 class="ui header">Send Message to Peer Plugin</h2>
<div class="ui form" id="messageForm">
<div class="field">
<label for="messageInput">Message:</label>
<input type="text" id="messageInput" name="message" placeholder="Enter your message" required>
</div>
<button class="ui primary button" id="sendMessageButton">Send Message</button>
</div>
</div>
<script>
// Handle form submission
$('#sendMessageButton').click(function(event) {
event.preventDefault();
const message = $('#messageInput').val();
$.cjax({
url: './api/send_message',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ message: message }),
success: function(response) {
showToast('Message sent!');
// Log the sent message
const sentMessage = $('<div class="item sent-message"><div class="content"><div class="header">Sent:</div><div class="description">' + message + '</div></div></div>');
$('#messageLog').prepend(sentMessage);
$('#messageInput').val(''); // Clear input field
},
error: function(xhr, status, error) {
showToast('Error sending message!', 'error');
console.error('Error:', error);
}
});
});
</script>
<div class="ui divider"></div>
<!-- Message Log -->
<div class="ui segment">
<h2 class="ui header">Messages</h2>
<div id="messageLog" class="ui relaxed divided list" style="max-height: 300px; overflow-y: auto;">
<!-- Messages will be appended here -->
</div>
</div>
<script>
// Set up EventSource to listen for incoming messages
const eventSource = new EventSource('./api/events');
eventSource.onmessage = function(e) {
const event = JSON.parse(e.data);
if (event && event.payload && event.payload.message) {
const receivedMessage = $('<div class="item received-message"><div class="content"><div class="header">Received:</div><div class="description">' + event.payload.message + '</div></div></div>');
$('#messageLog').prepend(receivedMessage);
}
showToast('New message received!');
};
eventSource.onerror = function(err) {
console.error('EventSource failed:', err);
eventSource.close();
};
// Clean up EventSource on page unload
window.addEventListener('beforeunload', function() {
eventSource.close();
});
// Fetch and display message history on page load
$(document).ready(function() {
$.cjax({
url: './api/message_history',
type: 'GET',
success: function(response) {
if (response && response.messages) {
response.messages.forEach(function(msg) {
const messageClass = msg.sent ? 'sent-message' : 'received-message';
const messageItem = $('<div class="item ' + messageClass + '"><div class="content"><div class="header">' + (msg.sent ? 'Sent:' : 'Received:') + '</div><div class="description">' + msg.message + '</div></div></div>');
$('#messageLog').append(messageItem);
});
}
},
error: function(xhr, status, error) {
console.error('Error fetching message history:', error);
}
});
});
</script>
</div>
</body>
</html>

View File

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
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
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* 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, see Zoraxy documentation for more details
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
/* 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
}
/*
@@ -126,8 +135,10 @@ 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
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
}
@@ -156,12 +167,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

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
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
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* 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, see Zoraxy documentation for more details
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
/* 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
}
/*
@@ -126,8 +135,10 @@ 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
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
}
@@ -156,12 +167,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

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -145,6 +145,24 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser
})
}
// HandleFunc registers a handler function for the given pattern
// The pattern should start with the handler prefix, e.g. /ui/hello
// If the pattern does not start with the handler prefix, it will be prepended with the handler prefix
func (p *PluginUiRouter) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request), mux *http.ServeMux) {
// If mux is nil, use the default ServeMux
if mux == nil {
mux = http.DefaultServeMux
}
// Make sure the pattern starts with the handler prefix
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
// Register the handler with the http.ServeMux
mux.HandleFunc(pattern, handler)
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {

View File

@@ -0,0 +1,181 @@
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
// Returns the "source" of the event, that is, the component or plugin that emitted the event
GetEventSource() string
}
// Event represents a system event
type Event struct {
Name EventName `json:"name"`
Timestamp int64 `json:"timestamp"` // Unix timestamp
UUID string `json:"uuid"` // UUID for the event
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"
// A custom event emitted by a plugin, with the intention of being broadcast
// to the designated recipient(s)
EventCustom EventName = "customEvent"
// A dummy event to satisfy the requirement of having at least one event
EventDummy EventName = "dummy"
// Add more event types as needed
)
var validEventNames = map[EventName]bool{
EventBlacklistedIPBlocked: true,
EventBlacklistToggled: true,
EventAccessRuleCreated: true,
EventCustom: true,
EventDummy: true,
// Add more event types as needed
// NOTE: Keep up-to-date with event names specified above
}
// Check if the event name is valid
func (name EventName) IsValid() bool {
return validEventNames[name]
}
// 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
}
func (e *BlacklistedIPBlockedEvent) GetEventSource() string {
return "proxy-access"
}
// 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
}
func (e *BlacklistToggledEvent) GetEventSource() string {
return "accesslist-api"
}
// 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
}
func (e *AccessRuleCreatedEvent) GetEventSource() string {
return "accesslist-api"
}
type CustomEvent struct {
SourcePlugin string `json:"source_plugin"`
Recipients []string `json:"recipients"`
Payload map[string]any `json:"payload"`
}
func (e *CustomEvent) GetName() EventName {
return EventCustom
}
func (e *CustomEvent) GetEventSource() string {
return e.SourcePlugin
}
// 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"`
UUID string `json:"uuid"`
}
if err := json.Unmarshal(jsonData, &temp); err != nil {
return err
}
// Set the event name and timestamp
event.Name = temp.Name
event.Timestamp = temp.Timestamp
event.UUID = temp.UUID
// 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
case EventCustom:
type tempData struct {
Data CustomEvent `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

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
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
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* 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, see Zoraxy documentation for more details
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
/* 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
}
/*
@@ -126,8 +135,10 @@ 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
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
}
@@ -156,12 +167,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

@@ -1,5 +1,5 @@
# PLATFORMS := darwin/amd64 darwin/arm64 freebsd/amd64 linux/386 linux/amd64 linux/arm linux/arm64 linux/mipsle windows/386 windows/amd64 windows/arm windows/arm64
PLATFORMS := linux/amd64 linux/386 linux/arm linux/arm64 linux/mipsle linux/riscv64 windows/amd64 freebsd/amd64
PLATFORMS := linux/amd64 linux/386 linux/arm linux/arm64 linux/mipsle linux/riscv64 windows/amd64 freebsd/amd64 darwin/amd64
temp = $(subst /, ,$@)
os = $(word 1, $(temp))
arch = $(word 2, $(temp))

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)
}
}
@@ -566,11 +584,12 @@ func handleWhitelistAllowLoopback(w http.ResponseWriter, r *http.Request) {
js, _ := json.Marshal(currentEnabled)
utils.SendJSONResponse(w, string(js))
} else {
if enable == "true" {
switch enable {
case "true":
rule.ToggleAllowLoopback(true)
} else if enable == "false" {
case "false":
rule.ToggleAllowLoopback(false)
} else {
default:
utils.SendErrorResponse(w, "invalid enable state: only true and false is accepted")
return
}

View File

@@ -382,7 +382,9 @@ func initAPIs(targetMux *http.ServeMux) {
authRouter.HandleFunc("/api/conf/import", ImportConfigFromZip)
authRouter.HandleFunc("/api/log/list", LogViewer.HandleListLog)
authRouter.HandleFunc("/api/log/read", LogViewer.HandleReadLog)
authRouter.HandleFunc("/api/log/summary", LogViewer.HandleReadLogSummary)
authRouter.HandleFunc("/api/log/errors", LogViewer.HandleLogErrorSummary)
authRouter.HandleFunc("/api/log/rotate/debug.trigger", SystemWideLogger.HandleDebugTriggerLogRotation)
//Debug
authRouter.HandleFunc("/api/info/pprof", pprof.Index)
}

View File

@@ -44,7 +44,7 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.2.5"
SYSTEM_VERSION = "3.2.6"
DEVELOPMENT_BUILD = false
/* System Constants */
@@ -94,6 +94,11 @@ var (
allowWebFileManager = flag.Bool("webfm", true, "Enable web file manager for static web server root folder")
enableAutoUpdate = flag.Bool("cfgupgrade", true, "Enable auto config upgrade if breaking change is detected")
/* Logging Configuration Flags */
enableLog = flag.Bool("enablelog", true, "Enable system wide logging, set to false for writing log to STDOUT only")
enableLogCompression = flag.Bool("enablelogcompress", true, "Enable log compression for rotated log files")
logRotate = flag.Int("logrotate", 0, "Enable log rotation and set the maximum log file size in KB (e.g. 25 for 25KB), set to 0 for disable")
/* Default Configuration Flags */
defaultInboundPort = flag.Int("default_inbound_port", 443, "Default web server listening port")
defaultEnableInboundTraffic = flag.Bool("default_inbound_enabled", true, "If web server is enabled by default")
@@ -149,6 +154,9 @@ var (
loadBalancer *loadbalance.RouteManager //Global scope loadbalancer, store the state of the lb routing
pluginManager *plugins.Manager //Plugin manager for managing plugins
//Plugin auth related
pluginApiKeyManager *auth.APIKeyManager //API key manager for plugin authentication
//Authentication Provider
forwardAuthRouter *forward.AuthRouter // Forward Auth router for Authelia/Authentik/etc authentication
oauth2Router *oauth2.OAuth2Router //OAuth2Router router for OAuth2Router authentication

View File

@@ -1,14 +1,14 @@
module imuslab.com/zoraxy
go 1.22.0
go 1.24.0
toolchain go1.22.2
toolchain go1.24.6
require (
github.com/armon/go-radix v1.0.0
github.com/boltdb/bolt v1.3.1
github.com/docker/docker v27.0.0+incompatible
github.com/go-acme/lego/v4 v4.21.0
github.com/go-acme/lego/v4 v4.25.2
github.com/go-ping/ping v1.1.0
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.2.2
@@ -20,109 +20,121 @@ require (
github.com/shirou/gopsutil/v4 v4.25.1
github.com/stretchr/testify v1.10.0
github.com/syndtr/goleveldb v1.0.0
golang.org/x/net v0.33.0
golang.org/x/oauth2 v0.24.0
golang.org/x/text v0.21.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/text v0.27.0
)
require (
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.8 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
github.com/alibabacloud-go/tea v1.3.9 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/credentials-go v1.4.6 // indirect
github.com/aziontech/azionapi-go-sdk v0.142.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.235 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/dnsimple/dnsimple-go/v4 v4.0.0 // indirect
github.com/go-acme/alidns-20150109/v4 v4.5.10 // indirect
github.com/go-acme/tencentclouddnspod v1.0.1208 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 // indirect
github.com/namedotcom/go/v4 v4.0.2 // indirect
github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
github.com/yandex-cloud/go-sdk/services/dns v0.0.3 // indirect
github.com/yandex-cloud/go-sdk/v2 v2.0.8 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
)
require (
cloud.google.com/go/auth v0.13.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.29 // indirect
github.com/Azure/go-autorest/autorest v0.11.30 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.72 // indirect
github.com/aws/aws-sdk-go-v2 v1.32.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.28.7 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.8 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.18 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.71 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.43.5 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.53.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.1 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/civo/civogo v0.3.11 // indirect
github.com/cloudflare/cloudflare-go v0.112.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-resty/resty/v2 v2.16.2 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/gorilla/csrf v1.7.2
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.128 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.159 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/linode/linodego v1.44.0 // indirect
github.com/linode/linodego v1.53.0 // indirect
github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/miekg/dns v1.1.67 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -131,13 +143,12 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect
github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect
github.com/nrdcg/desec v0.10.0 // indirect
github.com/nrdcg/bunny-go v0.0.0-20250327222614-988a091fc7ea // indirect
github.com/nrdcg/desec v0.11.0 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.3.0 // indirect
github.com/nrdcg/goinwx v0.10.0 // indirect
github.com/nrdcg/goinwx v0.11.0 // indirect
github.com/nrdcg/mailinabox v0.2.0 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/nrdcg/nodion v0.1.0 // indirect
@@ -145,57 +156,51 @@ require (
github.com/nzdjb/go-metaname v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/ovh/go-ovh v1.6.0 // indirect
github.com/ovh/go-ovh v1.9.0 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/pquerna/otp v1.4.0 // indirect
github.com/sacloud/api-client-go v0.2.10 // indirect
github.com/sacloud/go-http v0.1.8 // indirect
github.com/sacloud/iaas-api-go v1.14.0 // indirect
github.com/sacloud/packages-go v0.0.10 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 // indirect
github.com/shopspring/decimal v1.3.1 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/sacloud/api-client-go v0.3.2 // indirect
github.com/sacloud/go-http v0.1.9 // indirect
github.com/sacloud/iaas-api-go v1.16.1 // indirect
github.com/sacloud/packages-go v0.0.11 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.34 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.1.7 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1065 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1065 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1210 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/transip/gotransip/v6 v6.26.0 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect
github.com/vultr/govultr/v3 v3.9.1 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20241220122821-aeb3b05efd1c // indirect
github.com/yandex-cloud/go-sdk v0.0.0-20241220131134-2393e243c134 // indirect
github.com/vultr/govultr/v3 v3.21.1 // indirect
github.com/yandex-cloud/go-genproto v0.14.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver v1.12.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.mongodb.org/mongo-driver v1.13.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.uber.org/ratelimit v0.3.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.28.0 // indirect
google.golang.org/api v0.214.0 // indirect
google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.35.2 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.14.4 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.1 // indirect

File diff suppressed because it is too large Load Diff

View File

@@ -99,8 +99,9 @@ func main() {
}
nodeUUID = string(uuidBytes)
//Create a new webmin mux and csrf middleware layer
//Create a new webmin mux, plugin mux and csrf middleware layer
webminPanelMux = http.NewServeMux()
pluginAPIMux := http.NewServeMux()
csrfMiddleware = csrf.Protect(
[]byte(nodeUUID),
csrf.CookieName(CSRF_COOKIENAME),
@@ -112,13 +113,19 @@ func main() {
//Startup all modules, see start.go
startupSequence()
//Initiate management interface APIs
//Initiate APIs
requireAuth = !(*noauth)
initAPIs(webminPanelMux)
initRestAPI(pluginAPIMux)
//Start the reverse proxy server in go routine
// Create a entry mux to accept all management interface requests
entryMux := http.NewServeMux()
entryMux.Handle("/plugin/", pluginAPIMux) //For plugins API access
entryMux.Handle("/", csrfMiddleware(webminPanelMux)) //For webmin UI access, require csrf token
// Start the reverse proxy server in go routine
go func() {
ReverseProxtInit()
ReverseProxyInit()
}()
time.Sleep(500 * time.Millisecond)
@@ -133,7 +140,7 @@ func main() {
SystemWideLogger.Println(SYSTEM_NAME + " started. Visit control panel at http://" + *webUIPort)
}
err = http.ListenAndServe(*webUIPort, csrfMiddleware(webminPanelMux))
err = http.ListenAndServe(*webUIPort, entryMux)
if err != nil {
log.Fatal(err)

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

@@ -1,5 +1,4 @@
package acmedns
/*
THIS MODULE IS GENERATED AUTOMATICALLY
DO NOT EDIT THIS FILE
@@ -10,15 +9,20 @@ import (
"time"
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/providers/dns/active24"
"github.com/go-acme/lego/v4/providers/dns/alidns"
"github.com/go-acme/lego/v4/providers/dns/allinkl"
"github.com/go-acme/lego/v4/providers/dns/arvancloud"
"github.com/go-acme/lego/v4/providers/dns/auroradns"
"github.com/go-acme/lego/v4/providers/dns/autodns"
"github.com/go-acme/lego/v4/providers/dns/axelname"
"github.com/go-acme/lego/v4/providers/dns/azion"
"github.com/go-acme/lego/v4/providers/dns/azure"
"github.com/go-acme/lego/v4/providers/dns/azuredns"
"github.com/go-acme/lego/v4/providers/dns/baiducloud"
"github.com/go-acme/lego/v4/providers/dns/bindman"
"github.com/go-acme/lego/v4/providers/dns/bluecat"
"github.com/go-acme/lego/v4/providers/dns/bookmyname"
"github.com/go-acme/lego/v4/providers/dns/brandit"
"github.com/go-acme/lego/v4/providers/dns/bunny"
"github.com/go-acme/lego/v4/providers/dns/checkdomain"
@@ -29,13 +33,13 @@ import (
"github.com/go-acme/lego/v4/providers/dns/cloudru"
"github.com/go-acme/lego/v4/providers/dns/cloudxns"
"github.com/go-acme/lego/v4/providers/dns/conoha"
"github.com/go-acme/lego/v4/providers/dns/conohav3"
"github.com/go-acme/lego/v4/providers/dns/constellix"
"github.com/go-acme/lego/v4/providers/dns/cpanel"
"github.com/go-acme/lego/v4/providers/dns/derak"
"github.com/go-acme/lego/v4/providers/dns/desec"
"github.com/go-acme/lego/v4/providers/dns/digitalocean"
"github.com/go-acme/lego/v4/providers/dns/directadmin"
"github.com/go-acme/lego/v4/providers/dns/dnshomede"
"github.com/go-acme/lego/v4/providers/dns/dnsimple"
"github.com/go-acme/lego/v4/providers/dns/dnsmadeeasy"
"github.com/go-acme/lego/v4/providers/dns/dnspod"
@@ -44,10 +48,12 @@ import (
"github.com/go-acme/lego/v4/providers/dns/dreamhost"
"github.com/go-acme/lego/v4/providers/dns/duckdns"
"github.com/go-acme/lego/v4/providers/dns/dyn"
"github.com/go-acme/lego/v4/providers/dns/dyndnsfree"
"github.com/go-acme/lego/v4/providers/dns/dynu"
"github.com/go-acme/lego/v4/providers/dns/easydns"
"github.com/go-acme/lego/v4/providers/dns/efficientip"
"github.com/go-acme/lego/v4/providers/dns/epik"
"github.com/go-acme/lego/v4/providers/dns/f5xc"
"github.com/go-acme/lego/v4/providers/dns/freemyip"
"github.com/go-acme/lego/v4/providers/dns/gandi"
"github.com/go-acme/lego/v4/providers/dns/gandiv5"
@@ -80,7 +86,9 @@ import (
"github.com/go-acme/lego/v4/providers/dns/loopia"
"github.com/go-acme/lego/v4/providers/dns/luadns"
"github.com/go-acme/lego/v4/providers/dns/mailinabox"
"github.com/go-acme/lego/v4/providers/dns/manageengine"
"github.com/go-acme/lego/v4/providers/dns/metaname"
"github.com/go-acme/lego/v4/providers/dns/metaregistrar"
"github.com/go-acme/lego/v4/providers/dns/mijnhost"
"github.com/go-acme/lego/v4/providers/dns/mittwald"
"github.com/go-acme/lego/v4/providers/dns/mydnsjp"
@@ -91,6 +99,7 @@ import (
"github.com/go-acme/lego/v4/providers/dns/netcup"
"github.com/go-acme/lego/v4/providers/dns/netlify"
"github.com/go-acme/lego/v4/providers/dns/nicmanager"
"github.com/go-acme/lego/v4/providers/dns/nicru"
"github.com/go-acme/lego/v4/providers/dns/nifcloud"
"github.com/go-acme/lego/v4/providers/dns/njalla"
"github.com/go-acme/lego/v4/providers/dns/nodion"
@@ -101,7 +110,9 @@ import (
"github.com/go-acme/lego/v4/providers/dns/plesk"
"github.com/go-acme/lego/v4/providers/dns/porkbun"
"github.com/go-acme/lego/v4/providers/dns/rackspace"
"github.com/go-acme/lego/v4/providers/dns/rainyun"
"github.com/go-acme/lego/v4/providers/dns/rcodezero"
"github.com/go-acme/lego/v4/providers/dns/regfish"
"github.com/go-acme/lego/v4/providers/dns/regru"
"github.com/go-acme/lego/v4/providers/dns/rfc2136"
"github.com/go-acme/lego/v4/providers/dns/rimuhosting"
@@ -115,7 +126,9 @@ import (
"github.com/go-acme/lego/v4/providers/dns/shellrent"
"github.com/go-acme/lego/v4/providers/dns/simply"
"github.com/go-acme/lego/v4/providers/dns/sonic"
"github.com/go-acme/lego/v4/providers/dns/spaceship"
"github.com/go-acme/lego/v4/providers/dns/stackpath"
"github.com/go-acme/lego/v4/providers/dns/technitium"
"github.com/go-acme/lego/v4/providers/dns/tencentcloud"
"github.com/go-acme/lego/v4/providers/dns/transip"
"github.com/go-acme/lego/v4/providers/dns/ultradns"
@@ -130,20 +143,32 @@ import (
"github.com/go-acme/lego/v4/providers/dns/webnames"
"github.com/go-acme/lego/v4/providers/dns/websupport"
"github.com/go-acme/lego/v4/providers/dns/wedos"
"github.com/go-acme/lego/v4/providers/dns/westcn"
"github.com/go-acme/lego/v4/providers/dns/yandex"
"github.com/go-acme/lego/v4/providers/dns/yandex360"
"github.com/go-acme/lego/v4/providers/dns/yandexcloud"
"github.com/go-acme/lego/v4/providers/dns/zoneedit"
"github.com/go-acme/lego/v4/providers/dns/zoneee"
"github.com/go-acme/lego/v4/providers/dns/zonomi"
)
// name is the DNS provider name, e.g. cloudflare or gandi
// JSON (js) must be in key-value string that match ConfigableFields Title in providers.json, e.g. {"Username":"far","Password":"boo"}
func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64, pollingInterval int64) (challenge.Provider, error) {
//name is the DNS provider name, e.g. cloudflare or gandi
//JSON (js) must be in key-value string that match ConfigableFields Title in providers.json, e.g. {"Username":"far","Password":"boo"}
func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64, pollingInterval int64)(challenge.Provider, error){
pgDuration := time.Duration(propagationTimeout) * time.Second
plInterval := time.Duration(pollingInterval) * time.Second
switch name {
case "active24":
cfg := active24.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return active24.NewDNSProviderConfig(cfg)
case "alidns":
cfg := alidns.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -189,6 +214,24 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return autodns.NewDNSProviderConfig(cfg)
case "axelname":
cfg := axelname.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return axelname.NewDNSProviderConfig(cfg)
case "azion":
cfg := azion.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return azion.NewDNSProviderConfig(cfg)
case "azure":
cfg := azure.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -207,6 +250,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return azuredns.NewDNSProviderConfig(cfg)
case "baiducloud":
cfg := baiducloud.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return baiducloud.NewDNSProviderConfig(cfg)
case "bindman":
cfg := bindman.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -225,6 +277,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return bluecat.NewDNSProviderConfig(cfg)
case "bookmyname":
cfg := bookmyname.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return bookmyname.NewDNSProviderConfig(cfg)
case "brandit":
cfg := brandit.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -315,6 +376,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return conoha.NewDNSProviderConfig(cfg)
case "conohav3":
cfg := conohav3.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return conohav3.NewDNSProviderConfig(cfg)
case "constellix":
cfg := constellix.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -369,15 +439,6 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return directadmin.NewDNSProviderConfig(cfg)
case "dnshomede":
cfg := dnshomede.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return dnshomede.NewDNSProviderConfig(cfg)
case "dnsimple":
cfg := dnsimple.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -450,6 +511,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return dyn.NewDNSProviderConfig(cfg)
case "dyndnsfree":
cfg := dyndnsfree.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return dyndnsfree.NewDNSProviderConfig(cfg)
case "dynu":
cfg := dynu.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -486,6 +556,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return epik.NewDNSProviderConfig(cfg)
case "f5xc":
cfg := f5xc.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return f5xc.NewDNSProviderConfig(cfg)
case "freemyip":
cfg := freemyip.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -774,6 +853,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return mailinabox.NewDNSProviderConfig(cfg)
case "manageengine":
cfg := manageengine.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return manageengine.NewDNSProviderConfig(cfg)
case "metaname":
cfg := metaname.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -783,6 +871,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return metaname.NewDNSProviderConfig(cfg)
case "metaregistrar":
cfg := metaregistrar.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return metaregistrar.NewDNSProviderConfig(cfg)
case "mijnhost":
cfg := mijnhost.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -873,6 +970,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return nicmanager.NewDNSProviderConfig(cfg)
case "nicru":
cfg := nicru.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return nicru.NewDNSProviderConfig(cfg)
case "nifcloud":
cfg := nifcloud.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -963,6 +1069,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return rackspace.NewDNSProviderConfig(cfg)
case "rainyun":
cfg := rainyun.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return rainyun.NewDNSProviderConfig(cfg)
case "rcodezero":
cfg := rcodezero.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -972,6 +1087,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return rcodezero.NewDNSProviderConfig(cfg)
case "regfish":
cfg := regfish.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return regfish.NewDNSProviderConfig(cfg)
case "regru":
cfg := regru.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -1089,6 +1213,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return sonic.NewDNSProviderConfig(cfg)
case "spaceship":
cfg := spaceship.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return spaceship.NewDNSProviderConfig(cfg)
case "stackpath":
cfg := stackpath.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -1098,6 +1231,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return stackpath.NewDNSProviderConfig(cfg)
case "technitium":
cfg := technitium.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return technitium.NewDNSProviderConfig(cfg)
case "tencentcloud":
cfg := tencentcloud.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -1224,6 +1366,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return wedos.NewDNSProviderConfig(cfg)
case "westcn":
cfg := westcn.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return westcn.NewDNSProviderConfig(cfg)
case "yandex":
cfg := yandex.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
@@ -1251,6 +1402,15 @@ func GetDNSProviderByJsonConfig(name string, js string, propagationTimeout int64
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return yandexcloud.NewDNSProviderConfig(cfg)
case "zoneedit":
cfg := zoneedit.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)
if err != nil {
return nil, err
}
cfg.PropagationTimeout = pgDuration
cfg.PollingInterval = plInterval
return zoneedit.NewDNSProviderConfig(cfg)
case "zoneee":
cfg := zoneee.NewDefaultConfig()
err := json.Unmarshal([]byte(js), &cfg)

View File

@@ -1,4 +1,31 @@
{
"active24": {
"Name": "active24",
"ConfigableFields": [
{
"Title": "APIKey",
"Datatype": "string"
},
{
"Title": "Secret",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"alidns": {
"Name": "alidns",
"ConfigableFields": [
@@ -144,6 +171,60 @@
}
]
},
"axelname": {
"Name": "axelname",
"ConfigableFields": [
{
"Title": "Nickname",
"Datatype": "string"
},
{
"Title": "Token",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"azion": {
"Name": "azion",
"ConfigableFields": [
{
"Title": "PersonalToken",
"Datatype": "string"
},
{
"Title": "PageSize",
"Datatype": "int"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"azure": {
"Name": "azure",
"ConfigableFields": [
@@ -278,6 +359,28 @@
}
]
},
"baiducloud": {
"Name": "baiducloud",
"ConfigableFields": [
{
"Title": "AccessKeyID",
"Datatype": "string"
},
{
"Title": "SecretAccessKey",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": []
},
"bindman": {
"Name": "bindman",
"ConfigableFields": [
@@ -348,6 +451,33 @@
}
]
},
"bookmyname": {
"Name": "bookmyname",
"ConfigableFields": [
{
"Title": "Username",
"Datatype": "string"
},
{
"Title": "Password",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"brandit": {
"Name": "brandit",
"ConfigableFields": [
@@ -423,10 +553,6 @@
"civo": {
"Name": "civo",
"ConfigableFields": [
{
"Title": "ProjectID",
"Datatype": "string"
},
{
"Title": "Token",
"Datatype": "string"
@@ -440,7 +566,12 @@
"Datatype": "time.Duration"
}
],
"HiddenFields": []
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"clouddns": {
"Name": "clouddns",
@@ -492,6 +623,10 @@
"Title": "ZoneToken",
"Datatype": "string"
},
{
"Title": "BaseURL",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
@@ -632,6 +767,41 @@
}
]
},
"conohav3": {
"Name": "conohav3",
"ConfigableFields": [
{
"Title": "Region",
"Datatype": "string"
},
{
"Title": "TenantID",
"Datatype": "string"
},
{
"Title": "UserID",
"Datatype": "string"
},
{
"Title": "Password",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"constellix": {
"Name": "constellix",
"ConfigableFields": [
@@ -806,29 +976,6 @@
}
]
},
"dnshomede": {
"Name": "dnshomede",
"ConfigableFields": [
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "Credentials",
"Datatype": "map[string]string"
},
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"dnsimple": {
"Name": "dnsimple",
"ConfigableFields": [
@@ -1044,6 +1191,33 @@
}
]
},
"dyndnsfree": {
"Name": "dyndnsfree",
"ConfigableFields": [
{
"Title": "Username",
"Datatype": "string"
},
{
"Title": "Password",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"dynu": {
"Name": "dynu",
"ConfigableFields": [
@@ -1164,6 +1338,37 @@
}
]
},
"f5xc": {
"Name": "f5xc",
"ConfigableFields": [
{
"Title": "APIToken",
"Datatype": "string"
},
{
"Title": "TenantName",
"Datatype": "string"
},
{
"Title": "GroupName",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"freemyip": {
"Name": "freemyip",
"ConfigableFields": [
@@ -1608,6 +1813,10 @@
"Title": "SSLVerify",
"Datatype": "bool"
},
{
"Title": "CACertificate",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
@@ -2020,6 +2229,28 @@
],
"HiddenFields": []
},
"manageengine": {
"Name": "manageengine",
"ConfigableFields": [
{
"Title": "ClientID",
"Datatype": "string"
},
{
"Title": "ClientSecret",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": []
},
"metaname": {
"Name": "metaname",
"ConfigableFields": [
@@ -2042,6 +2273,29 @@
],
"HiddenFields": []
},
"metaregistrar": {
"Name": "metaregistrar",
"ConfigableFields": [
{
"Title": "APIToken",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"mijnhost": {
"Name": "mijnhost",
"ConfigableFields": [
@@ -2327,6 +2581,36 @@
}
]
},
"nicru": {
"Name": "nicru",
"ConfigableFields": [
{
"Title": "Username",
"Datatype": "string"
},
{
"Title": "Password",
"Datatype": "string"
},
{
"Title": "ServiceID",
"Datatype": "string"
},
{
"Title": "Secret",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": []
},
"nifcloud": {
"Name": "nifcloud",
"ConfigableFields": [
@@ -2633,6 +2917,29 @@
}
]
},
"rainyun": {
"Name": "rainyun",
"ConfigableFields": [
{
"Title": "APIKey",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"rcodezero": {
"Name": "rcodezero",
"ConfigableFields": [
@@ -2656,6 +2963,29 @@
}
]
},
"regfish": {
"Name": "regfish",
"ConfigableFields": [
{
"Title": "APIKey",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"regru": {
"Name": "regru",
"ConfigableFields": [
@@ -2698,6 +3028,10 @@
"Title": "Nameserver",
"Datatype": "string"
},
{
"Title": "TSIGFile",
"Datatype": "string"
},
{
"Title": "TSIGAlgorithm",
"Datatype": "string"
@@ -2789,6 +3123,10 @@
}
],
"HiddenFields": [
{
"Title": "PrivateZone",
"Datatype": "bool"
},
{
"Title": "WaitForRecordSetsChanged",
"Datatype": "bool"
@@ -3045,6 +3383,33 @@
}
]
},
"spaceship": {
"Name": "spaceship",
"ConfigableFields": [
{
"Title": "APIKey",
"Datatype": "string"
},
{
"Title": "APISecret",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"stackpath": {
"Name": "stackpath",
"ConfigableFields": [
@@ -3071,6 +3436,33 @@
],
"HiddenFields": []
},
"technitium": {
"Name": "technitium",
"ConfigableFields": [
{
"Title": "BaseURL",
"Datatype": "string"
},
{
"Title": "APIToken",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"tencentcloud": {
"Name": "tencentcloud",
"ConfigableFields": [
@@ -3285,7 +3677,12 @@
"Datatype": "time.Duration"
}
],
"HiddenFields": []
"HiddenFields": [
{
"Title": "QuoteValue",
"Datatype": "bool"
}
]
},
"vkcloud": {
"Name": "vkcloud",
@@ -3452,6 +3849,33 @@
}
]
},
"westcn": {
"Name": "westcn",
"ConfigableFields": [
{
"Title": "Username",
"Datatype": "string"
},
{
"Title": "Password",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"yandex": {
"Name": "yandex",
"ConfigableFields": [
@@ -3524,6 +3948,33 @@
],
"HiddenFields": []
},
"zoneedit": {
"Name": "zoneedit",
"ConfigableFields": [
{
"Title": "User",
"Datatype": "string"
},
{
"Title": "AuthToken",
"Datatype": "string"
},
{
"Title": "PropagationTimeout",
"Datatype": "time.Duration"
},
{
"Title": "PollingInterval",
"Datatype": "time.Duration"
}
],
"HiddenFields": [
{
"Title": "HTTPClient",
"Datatype": "*http.Client"
}
]
},
"zoneee": {
"Name": "zoneee",
"ConfigableFields": [

View File

@@ -0,0 +1,112 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
)
// PluginAPIKey represents an API key for a plugin
type PluginAPIKey struct {
PluginID string
APIKey string
PermittedEndpoints []zoraxy_plugin.PermittedAPIEndpoint // List of permitted API endpoints
CreatedAt time.Time
}
// APIKeyManager manages API keys for plugins
type APIKeyManager struct {
keys map[string]*PluginAPIKey // key: API key, value: plugin info
mutex sync.RWMutex
}
// NewAPIKeyManager creates a new API key manager
func NewAPIKeyManager() *APIKeyManager {
return &APIKeyManager{
keys: make(map[string]*PluginAPIKey),
mutex: sync.RWMutex{},
}
}
// GenerateAPIKey generates a new API key for a plugin
func (m *APIKeyManager) GenerateAPIKey(pluginID string, permittedEndpoints []zoraxy_plugin.PermittedAPIEndpoint) (*PluginAPIKey, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
// Generate a cryptographically secure random key
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
}
// Hash the random bytes to create the API key
hash := sha256.Sum256(bytes)
apiKey := hex.EncodeToString(hash[:])
// Create the plugin API key
pluginAPIKey := &PluginAPIKey{
PluginID: pluginID,
APIKey: apiKey,
PermittedEndpoints: permittedEndpoints,
CreatedAt: time.Now(),
}
// Store the API key
m.keys[apiKey] = pluginAPIKey
return pluginAPIKey, nil
}
// ValidateAPIKey validates an API key and returns the associated plugin information
func (m *APIKeyManager) ValidateAPIKey(apiKey string) (*PluginAPIKey, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
pluginAPIKey, exists := m.keys[apiKey]
if !exists {
return nil, fmt.Errorf("invalid API key")
}
return pluginAPIKey, nil
}
// ValidateAPIKeyForEndpoint validates an API key for a specific endpoint
func (m *APIKeyManager) ValidateAPIKeyForEndpoint(endpoint string, method string, apiKey string) (*PluginAPIKey, error) {
pluginAPIKey, err := m.ValidateAPIKey(apiKey)
if err != nil {
return nil, err
}
// Check if the endpoint is permitted
for _, permittedEndpoint := range pluginAPIKey.PermittedEndpoints {
if permittedEndpoint.Endpoint == endpoint && permittedEndpoint.Method == method {
return pluginAPIKey, nil
}
}
return nil, fmt.Errorf("endpoint not permitted for this API key")
}
// RevokeAPIKeysForPlugin revokes all API keys for a specific plugin
func (m *APIKeyManager) RevokeAPIKeysForPlugin(pluginID string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
keysToRemove := []string{}
for apiKey, pluginAPIKey := range m.keys {
if pluginAPIKey.PluginID == pluginID {
keysToRemove = append(keysToRemove, apiKey)
}
}
for _, apiKey := range keysToRemove {
delete(m.keys, apiKey)
}
return nil
}

View File

@@ -0,0 +1,94 @@
// Handles the API-Key based authentication for plugins
package auth
import (
"errors"
"fmt"
"net/http"
"strings"
)
const (
PLUGIN_API_PREFIX = "/plugin"
)
type PluginMiddlewareOptions struct {
DeniedHandler http.HandlerFunc //Thing(s) to do when request is rejected
ApiKeyManager *APIKeyManager
TargetMux *http.ServeMux
}
// PluginAuthMiddleware provides authentication middleware for plugin API requests
type PluginAuthMiddleware struct {
option PluginMiddlewareOptions
endpoints map[string]http.HandlerFunc
}
// NewPluginAuthMiddleware creates a new plugin authentication middleware
func NewPluginAuthMiddleware(option PluginMiddlewareOptions) *PluginAuthMiddleware {
return &PluginAuthMiddleware{
option: option,
endpoints: make(map[string]http.HandlerFunc),
}
}
func (m *PluginAuthMiddleware) HandleAuthCheck(w http.ResponseWriter, r *http.Request, handler http.HandlerFunc) {
// Check for API key in the Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// No authorization header
m.option.DeniedHandler(w, r)
return
}
// Check if it's a plugin API key (Bearer token)
if !strings.HasPrefix(authHeader, "Bearer ") {
// Not a Bearer token
m.option.DeniedHandler(w, r)
return
}
// Extract the API key
apiKey := strings.TrimPrefix(authHeader, "Bearer ")
// Validate the API key for this endpoint
_, err := m.option.ApiKeyManager.ValidateAPIKeyForEndpoint(r.URL.Path, r.Method, apiKey)
if err != nil {
// Invalid API key or endpoint not permitted
m.option.DeniedHandler(w, r)
return
}
// Call the original handler
handler(w, r)
}
// wraps an HTTP handler with plugin authentication middleware
func (m *PluginAuthMiddleware) HandleFunc(endpoint string, handler http.HandlerFunc) error {
// ensure the endpoint is prefixed with PLUGIN_API_PREFIX
if !strings.HasPrefix(endpoint, PLUGIN_API_PREFIX) {
endpoint = PLUGIN_API_PREFIX + endpoint
}
// Check if the endpoint already registered
if _, exist := m.endpoints[endpoint]; exist {
fmt.Println("WARNING! Duplicated registering of plugin api endpoint: " + endpoint)
return errors.New("endpoint register duplicated")
}
m.endpoints[endpoint] = handler
wrappedHandler := func(w http.ResponseWriter, r *http.Request) {
m.HandleAuthCheck(w, r, handler)
}
// Ok. Register handler
if m.option.TargetMux == nil {
http.HandleFunc(endpoint, wrappedHandler)
} else {
m.option.TargetMux.HandleFunc(endpoint, wrappedHandler)
}
return nil
}

View File

@@ -13,14 +13,21 @@ const (
DatabaseKeyRequestHeaders = "requestHeaders"
DatabaseKeyRequestIncludedCookies = "requestIncludedCookies"
DatabaseKeyRequestExcludedCookies = "requestExcludedCookies"
DatabaseKeyRequestIncludeBody = "requestIncludeBody"
DatabaseKeyUseXOriginalHeaders = "useXOriginalHeaders"
HeaderXForwardedProto = "X-Forwarded-Proto"
HeaderXForwardedHost = "X-Forwarded-Host"
HeaderXForwardedFor = "X-Forwarded-For"
HeaderXForwardedURI = "X-Forwarded-URI"
HeaderXForwardedFor = "X-Forwarded-For"
HeaderXForwardedMethod = "X-Forwarded-Method"
HeaderCookie = "Cookie"
HeaderXOriginalURL = "X-Original-URL"
HeaderXOriginalIP = "X-Original-IP"
HeaderXOriginalMethod = "X-Original-Method"
HeaderCookie = "Cookie"
HeaderLocation = "Location"
HeaderUpgrade = "Upgrade"
HeaderConnection = "Connection"

View File

@@ -2,8 +2,10 @@ package forward
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"imuslab.com/zoraxy/mod/database"
@@ -34,6 +36,13 @@ type AuthRouterOptions struct {
// RequestExcludedCookies is a list of cookie keys that should be removed from every request sent to the upstream.
RequestExcludedCookies []string
// RequestIncludeBody enables copying the request body to the request to the authorization server.
RequestIncludeBody bool
// UseXOriginalHeaders is a boolean that determines if the X-Original-* headers should be used instead of the
// X-Forwarded-* headers.
UseXOriginalHeaders bool
Logger *logger.Logger
Database *database.Database
}
@@ -57,15 +66,8 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter {
options.Database.Read(DatabaseTable, DatabaseKeyRequestHeaders, &requestHeaders)
options.Database.Read(DatabaseTable, DatabaseKeyRequestIncludedCookies, &requestIncludedCookies)
options.Database.Read(DatabaseTable, DatabaseKeyRequestExcludedCookies, &requestExcludedCookies)
// Helper function to clean empty strings from split results
cleanSplit := func(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
options.Database.Read(DatabaseTable, DatabaseKeyRequestIncludeBody, &options.RequestIncludeBody)
options.Database.Read(DatabaseTable, DatabaseKeyUseXOriginalHeaders, &options.UseXOriginalHeaders)
options.ResponseHeaders = cleanSplit(responseHeaders)
options.ResponseClientHeaders = cleanSplit(responseClientHeaders)
@@ -73,7 +75,7 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter {
options.RequestIncludedCookies = cleanSplit(requestIncludedCookies)
options.RequestExcludedCookies = cleanSplit(requestExcludedCookies)
return &AuthRouter{
r := &AuthRouter{
client: &http.Client{
CheckRedirect: func(r *http.Request, via []*http.Request) (err error) {
return http.ErrUseLastResponse
@@ -81,6 +83,10 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter {
},
options: options,
}
r.logOptions()
return r
}
// HandleAPIOptions is the internal handler for setting the options.
@@ -90,6 +96,8 @@ func (ar *AuthRouter) HandleAPIOptions(w http.ResponseWriter, r *http.Request) {
ar.handleOptionsGET(w, r)
case http.MethodPost:
ar.handleOptionsPOST(w, r)
case http.MethodDelete:
ar.handleOptionsDelete(w, r)
default:
ar.handleOptionsMethodNotAllowed(w, r)
}
@@ -103,6 +111,8 @@ func (ar *AuthRouter) handleOptionsGET(w http.ResponseWriter, r *http.Request) {
DatabaseKeyRequestHeaders: ar.options.RequestHeaders,
DatabaseKeyRequestIncludedCookies: ar.options.RequestIncludedCookies,
DatabaseKeyRequestExcludedCookies: ar.options.RequestExcludedCookies,
DatabaseKeyRequestIncludeBody: ar.options.RequestIncludeBody,
DatabaseKeyUseXOriginalHeaders: ar.options.UseXOriginalHeaders,
})
utils.SendJSONResponse(w, string(js))
@@ -125,6 +135,8 @@ func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request)
requestHeaders, _ := utils.PostPara(r, DatabaseKeyRequestHeaders)
requestIncludedCookies, _ := utils.PostPara(r, DatabaseKeyRequestIncludedCookies)
requestExcludedCookies, _ := utils.PostPara(r, DatabaseKeyRequestExcludedCookies)
requestIncludeBody, _ := utils.PostPara(r, DatabaseKeyRequestIncludeBody)
useXOriginalHeaders, _ := utils.PostPara(r, DatabaseKeyUseXOriginalHeaders)
// Write changes to runtime
ar.options.Address = address
@@ -133,6 +145,8 @@ func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request)
ar.options.RequestHeaders = strings.Split(requestHeaders, ",")
ar.options.RequestIncludedCookies = strings.Split(requestIncludedCookies, ",")
ar.options.RequestExcludedCookies = strings.Split(requestExcludedCookies, ",")
ar.options.RequestIncludeBody, _ = strconv.ParseBool(requestIncludeBody)
ar.options.UseXOriginalHeaders, _ = strconv.ParseBool(useXOriginalHeaders)
// Write changes to database
ar.options.Database.Write(DatabaseTable, DatabaseKeyAddress, address)
@@ -141,6 +155,32 @@ func (ar *AuthRouter) handleOptionsPOST(w http.ResponseWriter, r *http.Request)
ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestHeaders, requestHeaders)
ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestIncludedCookies, requestIncludedCookies)
ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestExcludedCookies, requestExcludedCookies)
ar.options.Database.Write(DatabaseTable, DatabaseKeyRequestIncludeBody, ar.options.RequestIncludeBody)
ar.options.Database.Write(DatabaseTable, DatabaseKeyUseXOriginalHeaders, ar.options.UseXOriginalHeaders)
ar.logOptions()
utils.SendOK(w)
}
func (ar *AuthRouter) handleOptionsDelete(w http.ResponseWriter, r *http.Request) {
ar.options.Address = ""
ar.options.ResponseHeaders = nil
ar.options.ResponseClientHeaders = nil
ar.options.RequestHeaders = nil
ar.options.RequestIncludedCookies = nil
ar.options.RequestExcludedCookies = nil
ar.options.RequestIncludeBody = false
ar.options.UseXOriginalHeaders = false
ar.options.Database.Delete(DatabaseTable, DatabaseKeyAddress)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyResponseHeaders)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyResponseClientHeaders)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyRequestHeaders)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyRequestIncludedCookies)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyRequestExcludedCookies)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyRequestIncludeBody)
ar.options.Database.Delete(DatabaseTable, DatabaseKeyUseXOriginalHeaders)
utils.SendOK(w)
}
@@ -158,9 +198,6 @@ func (ar *AuthRouter) HandleAuthProviderRouting(w http.ResponseWriter, r *http.R
}
// Make a request to Authz Server to verify the request
// TODO: Add opt-in support for copying the request body to the forward auth request. Currently it's just an
// empty body which is usually fine in most instances. It's likely best to see if anyone wants this feature
// as I'm unaware of any specific forward auth implementation that needs it.
req, err := http.NewRequest(http.MethodGet, ar.options.Address, nil)
if err != nil {
return ar.handle500Error(w, err, "Unable to create request")
@@ -171,7 +208,17 @@ func (ar *AuthRouter) HandleAuthProviderRouting(w http.ResponseWriter, r *http.R
// TODO: Add support for headers from upstream proxies. This will likely involve implementing some form of
// proxy specific trust system within Zoraxy.
rSetForwardedHeaders(r, req)
if ar.options.UseXOriginalHeaders {
rSetXOriginalHeaders(r, req)
} else {
rSetXForwardedHeaders(r, req)
}
if ar.options.RequestIncludeBody {
if err = rCopyBody(r, req); err != nil {
return ar.handle500Error(w, err, "Unable to perform forwarded auth due to a request copy error")
}
}
// Make the Authz Request.
respForwarded, err := ar.client.Do(req)
@@ -202,15 +249,14 @@ func (ar *AuthRouter) HandleAuthProviderRouting(w http.ResponseWriter, r *http.R
// Copy the unsuccessful response.
headerCopyExcluded(respForwarded.Header, w.Header(), nil)
w.WriteHeader(respForwarded.StatusCode)
body, err := io.ReadAll(respForwarded.Body)
if err != nil {
return ar.handle500Error(w, err, "Unable to read response to forward auth request")
if ar.options.UseXOriginalHeaders && respForwarded.StatusCode == 401 && respForwarded.Header.Get(HeaderLocation) != "" {
w.WriteHeader(http.StatusFound)
} else {
w.WriteHeader(respForwarded.StatusCode)
}
if _, err = w.Write(body); err != nil {
return ar.handle500Error(w, err, "Unable to write response")
if _, err = io.Copy(w, respForwarded.Body); err != nil {
return ar.handle500Error(w, err, "Unable to copy response")
}
return ErrUnauthorized
@@ -224,3 +270,7 @@ func (ar *AuthRouter) handle500Error(w http.ResponseWriter, err error, message s
return ErrInternalServerError
}
func (ar *AuthRouter) logOptions() {
ar.options.Logger.PrintAndLog(LogTitle, fmt.Sprintf("Forward Authz Options -> Address: %s, Response Headers: %s, Response Client Headers: %s, Request Headers: %s, Request Included Cookies: %s, Request Excluded Cookies: %s, Request Include Body: %t, Use X-Original Headers: %t", ar.options.Address, strings.Join(ar.options.ResponseHeaders, ";"), strings.Join(ar.options.ResponseClientHeaders, ";"), strings.Join(ar.options.RequestHeaders, ";"), strings.Join(ar.options.RequestIncludedCookies, ";"), strings.Join(ar.options.RequestExcludedCookies, ";"), ar.options.RequestIncludeBody, ar.options.UseXOriginalHeaders), nil)
}

View File

@@ -1,8 +1,11 @@
package forward
import (
"bytes"
"io"
"net"
"net/http"
"net/url"
"strings"
)
@@ -121,17 +124,65 @@ func stringInSliceFold(needle string, haystack []string) bool {
return false
}
func rSetForwardedHeaders(r, req *http.Request) {
if r.RemoteAddr != "" {
before, _, _ := strings.Cut(r.RemoteAddr, ":")
if ip := net.ParseIP(before); ip != nil {
req.Header.Set(HeaderXForwardedFor, ip.String())
}
func rSetIPHeader(r, req *http.Request, headers ...string) {
if r.RemoteAddr == "" || len(headers) == 0 {
return
}
before, _, _ := strings.Cut(r.RemoteAddr, ":")
ip := net.ParseIP(before)
if ip == nil {
return
}
for _, header := range headers {
req.Header.Set(header, ip.String())
}
}
func rSetXForwardedHeaders(r, req *http.Request) {
rSetIPHeader(r, req, HeaderXForwardedFor)
req.Header.Set(HeaderXForwardedMethod, r.Method)
req.Header.Set(HeaderXForwardedProto, scheme(r))
req.Header.Set(HeaderXForwardedHost, r.Host)
req.Header.Set(HeaderXForwardedURI, r.URL.Path)
}
func rSetXOriginalHeaders(r, req *http.Request) {
// The X-Forwarded-For header has larger support, so we include both.
rSetIPHeader(r, req, HeaderXOriginalIP, HeaderXForwardedFor)
original := &url.URL{
Scheme: scheme(r),
Host: r.Host,
Path: r.URL.Path,
}
req.Header.Set(HeaderXOriginalMethod, r.Method)
req.Header.Set(HeaderXOriginalURL, original.String())
}
func rCopyBody(req, freq *http.Request) (err error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return err
}
if len(body) == 0 {
return nil
}
req.Body = io.NopCloser(bytes.NewReader(body))
freq.Body = io.NopCloser(bytes.NewReader(body))
return nil
}
func cleanSplit(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}

View File

@@ -66,86 +66,117 @@ func NewOAuth2Router(options *OAuth2RouterOptions) *OAuth2Router {
// HandleSetOAuth2Settings is the internal handler for setting the OAuth URL and HTTPS
func (ar *OAuth2Router) HandleSetOAuth2Settings(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
//Return the current settings
js, _ := json.Marshal(map[string]interface{}{
"oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl,
"oauth2ServerUrl": ar.options.OAuth2ServerURL,
"oauth2TokenUrl": ar.options.OAuth2TokenURL,
"oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl,
"oauth2Scopes": ar.options.OAuth2Scopes,
"oauth2ClientSecret": ar.options.OAuth2ClientSecret,
"oauth2ClientId": ar.options.OAuth2ClientId,
})
utils.SendJSONResponse(w, string(js))
return
} else if r.Method == http.MethodPost {
//Update the settings
var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl string
oauth2WellKnownUrl, err := utils.PostPara(r, "oauth2WellKnownUrl")
if err != nil {
oauth2ServerUrl, err = utils.PostPara(r, "oauth2ServerUrl")
if err != nil {
utils.SendErrorResponse(w, "oauth2ServerUrl not found")
return
}
oauth2TokenURL, err = utils.PostPara(r, "oauth2TokenUrl")
if err != nil {
utils.SendErrorResponse(w, "oauth2TokenUrl not found")
return
}
oauth2Scopes, err = utils.PostPara(r, "oauth2Scopes")
if err != nil {
utils.SendErrorResponse(w, "oauth2Scopes not found")
return
}
oauth2UserInfoUrl, err = utils.PostPara(r, "oauth2UserInfoUrl")
if err != nil {
utils.SendErrorResponse(w, "oauth2UserInfoUrl not found")
return
}
}
oauth2ClientId, err := utils.PostPara(r, "oauth2ClientId")
if err != nil {
utils.SendErrorResponse(w, "oauth2ClientId not found")
return
}
oauth2ClientSecret, err := utils.PostPara(r, "oauth2ClientSecret")
if err != nil {
utils.SendErrorResponse(w, "oauth2ClientSecret not found")
return
}
//Write changes to runtime
ar.options.OAuth2WellKnownUrl = oauth2WellKnownUrl
ar.options.OAuth2ServerURL = oauth2ServerUrl
ar.options.OAuth2TokenURL = oauth2TokenURL
ar.options.OAuth2UserInfoUrl = oauth2UserInfoUrl
ar.options.OAuth2ClientId = oauth2ClientId
ar.options.OAuth2ClientSecret = oauth2ClientSecret
ar.options.OAuth2Scopes = oauth2Scopes
//Write changes to database
ar.options.Database.Write("oauth2", "oauth2WellKnownUrl", oauth2WellKnownUrl)
ar.options.Database.Write("oauth2", "oauth2ServerUrl", oauth2ServerUrl)
ar.options.Database.Write("oauth2", "oauth2TokenUrl", oauth2TokenURL)
ar.options.Database.Write("oauth2", "oauth2UserInfoUrl", oauth2UserInfoUrl)
ar.options.Database.Write("oauth2", "oauth2ClientId", oauth2ClientId)
ar.options.Database.Write("oauth2", "oauth2ClientSecret", oauth2ClientSecret)
ar.options.Database.Write("oauth2", "oauth2Scopes", oauth2Scopes)
utils.SendOK(w)
} else {
switch r.Method {
case http.MethodGet:
ar.handleSetOAuthSettingsGET(w, r)
case http.MethodPost:
ar.handleSetOAuthSettingsPOST(w, r)
case http.MethodDelete:
ar.handleSetOAuthSettingsDELETE(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (ar *OAuth2Router) handleSetOAuthSettingsGET(w http.ResponseWriter, r *http.Request) {
//Return the current settings
js, _ := json.Marshal(map[string]interface{}{
"oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl,
"oauth2ServerUrl": ar.options.OAuth2ServerURL,
"oauth2TokenUrl": ar.options.OAuth2TokenURL,
"oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl,
"oauth2Scopes": ar.options.OAuth2Scopes,
"oauth2ClientSecret": ar.options.OAuth2ClientSecret,
"oauth2ClientId": ar.options.OAuth2ClientId,
})
utils.SendJSONResponse(w, string(js))
}
func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *http.Request) {
//Update the settings
var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl string
oauth2ClientId, err := utils.PostPara(r, "oauth2ClientId")
if err != nil {
utils.SendErrorResponse(w, "oauth2ClientId not found")
return
}
oauth2ClientSecret, err := utils.PostPara(r, "oauth2ClientSecret")
if err != nil {
utils.SendErrorResponse(w, "oauth2ClientSecret not found")
return
}
oauth2WellKnownUrl, err := utils.PostPara(r, "oauth2WellKnownUrl")
if err != nil {
oauth2ServerUrl, err = utils.PostPara(r, "oauth2ServerUrl")
if err != nil {
utils.SendErrorResponse(w, "oauth2ServerUrl not found")
return
}
oauth2TokenURL, err = utils.PostPara(r, "oauth2TokenUrl")
if err != nil {
utils.SendErrorResponse(w, "oauth2TokenUrl not found")
return
}
oauth2UserInfoUrl, err = utils.PostPara(r, "oauth2UserInfoUrl")
if err != nil {
utils.SendErrorResponse(w, "oauth2UserInfoUrl not found")
return
}
oauth2Scopes, err = utils.PostPara(r, "oauth2Scopes")
if err != nil {
utils.SendErrorResponse(w, "oauth2Scopes not found")
return
}
} else {
oauth2Scopes, _ = utils.PostPara(r, "oauth2Scopes")
}
//Write changes to runtime
ar.options.OAuth2WellKnownUrl = oauth2WellKnownUrl
ar.options.OAuth2ServerURL = oauth2ServerUrl
ar.options.OAuth2TokenURL = oauth2TokenURL
ar.options.OAuth2UserInfoUrl = oauth2UserInfoUrl
ar.options.OAuth2ClientId = oauth2ClientId
ar.options.OAuth2ClientSecret = oauth2ClientSecret
ar.options.OAuth2Scopes = oauth2Scopes
//Write changes to database
ar.options.Database.Write("oauth2", "oauth2WellKnownUrl", oauth2WellKnownUrl)
ar.options.Database.Write("oauth2", "oauth2ServerUrl", oauth2ServerUrl)
ar.options.Database.Write("oauth2", "oauth2TokenUrl", oauth2TokenURL)
ar.options.Database.Write("oauth2", "oauth2UserInfoUrl", oauth2UserInfoUrl)
ar.options.Database.Write("oauth2", "oauth2ClientId", oauth2ClientId)
ar.options.Database.Write("oauth2", "oauth2ClientSecret", oauth2ClientSecret)
ar.options.Database.Write("oauth2", "oauth2Scopes", oauth2Scopes)
utils.SendOK(w)
}
func (ar *OAuth2Router) handleSetOAuthSettingsDELETE(w http.ResponseWriter, r *http.Request) {
ar.options.OAuth2WellKnownUrl = ""
ar.options.OAuth2ServerURL = ""
ar.options.OAuth2TokenURL = ""
ar.options.OAuth2UserInfoUrl = ""
ar.options.OAuth2ClientId = ""
ar.options.OAuth2ClientSecret = ""
ar.options.OAuth2Scopes = ""
ar.options.Database.Delete("oauth2", "oauth2WellKnownUrl")
ar.options.Database.Delete("oauth2", "oauth2ServerUrl")
ar.options.Database.Delete("oauth2", "oauth2TokenUrl")
ar.options.Database.Delete("oauth2", "oauth2UserInfoUrl")
ar.options.Database.Delete("oauth2", "oauth2ClientId")
ar.options.Database.Delete("oauth2", "oauth2ClientSecret")
ar.options.Database.Delete("oauth2", "oauth2Scopes")
utils.SendOK(w)
}
func (ar *OAuth2Router) fetchOAuth2Configuration(config *oauth2.Config) (*oauth2.Config, error) {

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

@@ -6,6 +6,7 @@ import (
"strings"
"imuslab.com/zoraxy/mod/auth"
"imuslab.com/zoraxy/mod/netutils"
)
/*
@@ -70,9 +71,36 @@ func handleBasicAuth(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint)
if len(pe.AuthenticationProvider.BasicAuthExceptionRules) > 0 {
//Check if the current path matches the exception rules
for _, exceptionRule := range pe.AuthenticationProvider.BasicAuthExceptionRules {
if strings.HasPrefix(r.RequestURI, exceptionRule.PathPrefix) {
//This path is excluded from basic auth
return nil
exceptionType := exceptionRule.RuleType
switch exceptionType {
case AuthExceptionType_Paths:
if strings.HasPrefix(r.RequestURI, exceptionRule.PathPrefix) {
//This path is excluded from basic auth
return nil
}
case AuthExceptionType_CIDR:
requesterIp := netutils.GetRequesterIP(r)
if requesterIp != "" {
if requesterIp == exceptionRule.CIDR {
// This IP is excluded from basic auth
return nil
}
wildcardMatch := netutils.MatchIpWildcard(requesterIp, exceptionRule.CIDR)
if wildcardMatch {
// This IP is excluded from basic auth
return nil
}
cidrMatch := netutils.MatchIpCIDR(requesterIp, exceptionRule.CIDR)
if cidrMatch {
// This IP is excluded from basic auth
return nil
}
}
default:
//Unknown exception type, skip this rule
continue
}
}
}

View File

@@ -75,5 +75,9 @@ func SplitUpDownStreamHeaders(rewriteOptions *HeaderRewriteOptions) ([][]string,
downstreamHeaderCounter++
}
// Slice the arrays to only include the filled portions to prevent nil slice access
upstreamHeaders = upstreamHeaders[:upstreamHeaderCounter]
downstreamHeaders = downstreamHeaders[:downstreamHeaderCounter]
return upstreamHeaders, downstreamHeaders
}

View File

@@ -106,9 +106,18 @@ type BasicAuthUnhashedCredentials struct {
Password string
}
type AuthExceptionType int
const (
AuthExceptionType_Paths AuthExceptionType = iota //Path exception, match by path prefix
AuthExceptionType_CIDR //CIDR exception, match by CIDR
)
// Paths to exclude in basic auth enabled proxy handler
type BasicAuthExceptionRule struct {
PathPrefix string
RuleType AuthExceptionType //The type of the exception rule
PathPrefix string //Path prefix to match, e.g. /api/v1/
CIDR string //CIDR to match, e.g. 192.168.1.0/24 or IP address, e.g. 192.168.1.1
}
/* Routing Rule Data Structures */

View File

@@ -0,0 +1,177 @@
package eventsystem
import (
"sync"
"time"
"github.com/google/uuid"
"imuslab.com/zoraxy/mod/info/logger"
"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
}
// EmitToSubscribersAnd dispatches an event to the specific listeners in addition to the events subscribers.
//
// The primary use-case of this function is for plugin-to-plugin communication
func (em *eventManager) EmitToSubscribersAnd(listenerIDs []ListenerID, payload events.EventPayload) {
eventName := payload.GetName()
if len(listenerIDs) == 0 {
return // No listeners specified
}
// Create the event
event := events.Event{
Name: eventName,
Timestamp: time.Now().Unix(),
UUID: uuid.New().String(),
Data: payload,
}
// Dispatch to all specified listeners asynchronously
em.emitTo(listenerIDs, event)
// Also emit to all subscribers of the event as usual
em.mutex.RLock()
defer em.mutex.RUnlock()
subscribers, exists := em.subscriptions[eventName]
if !exists || len(subscribers) == 0 {
return // No subscribers
}
em.emitTo(subscribers, event)
}
// Emit dispatches an event to all subscribed listeners
func (em *eventManager) Emit(payload events.EventPayload) {
eventName := payload.GetName()
em.mutex.RLock()
defer em.mutex.RUnlock()
subscribers, exists := em.subscriptions[eventName]
if !exists || len(subscribers) == 0 {
return // No subscribers
}
// Create the event
event := events.Event{
Name: eventName,
Timestamp: time.Now().Unix(),
UUID: uuid.New().String(),
Data: payload,
}
// Dispatch to all subscribers asynchronously
em.emitTo(subscribers, event)
}
// Dispatch event to all specified listeners asynchronously
func (em *eventManager) emitTo(listenerIDs []ListenerID, event events.Event) {
if len(listenerIDs) == 0 {
return
}
// Dispatch to all specified listeners asynchronously
em.mutex.RLock()
defer em.mutex.RUnlock()
listenersToUnregister := []ListenerID{}
for _, listenerID := range listenerIDs {
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)
// Mark for removal
listenersToUnregister = append(listenersToUnregister, listenerID)
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)
}
// Unregister any listeners that no longer exist, asynchronously
go func() {
for _, id := range listenersToUnregister {
em.UnregisterSubscriber(id)
}
}()
}

View File

@@ -0,0 +1,387 @@
package eventsystem
import (
"encoding/json"
"fmt"
"testing"
"time"
"github.com/google/uuid"
"imuslab.com/zoraxy/mod/info/logger"
"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()
uuid := uuid.New().String()
tests := []SerializationTest{
{
name: "BlacklistedIPBlocked",
event: events.Event{
Name: events.EventBlacklistedIPBlocked,
Timestamp: timestamp,
UUID: uuid,
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) + `,"uuid":"` + uuid + `","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,
UUID: uuid,
Data: &events.BlacklistToggledEvent{
RuleID: "rule123",
Enabled: true,
},
},
expectedJson: `{"name":"blacklistToggled","timestamp":` + fmt.Sprintf("%d", timestamp) + `,"uuid":"` + uuid + `","data":{"rule_id":"rule123","enabled":true}}`,
},
{
name: "AccessRuleCreated",
event: events.Event{
Name: events.EventAccessRuleCreated,
Timestamp: timestamp,
UUID: uuid,
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) + `,"uuid":"` + uuid + `","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)
}
})
}
}
type TestListener struct {
id ListenerID
receivedEvents chan events.Event
}
func (tl *TestListener) GetID() ListenerID {
return tl.id
}
func (tl *TestListener) Notify(event events.Event) error {
tl.receivedEvents <- event
return nil
}
func TestEventEmission(t *testing.T) {
logger, err := logger.NewFmtLogger()
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}
em := eventManager{
subscriptions: make(map[events.EventName][]ListenerID),
subscribers: make(map[ListenerID]Listener),
logger: logger,
}
// Create a test listener
listenerID := ListenerID("testListener")
testListener := &TestListener{
id: listenerID,
receivedEvents: make(chan events.Event, 10),
}
// Register the listener for BlacklistedIPBlocked events
if err := em.RegisterSubscriberToEvent(testListener, events.EventBlacklistedIPBlocked); err != nil {
t.Fatalf("Failed to register subscriber: %v", err)
}
// Emit a BlacklistedIPBlocked event
testEvent := &events.BlacklistedIPBlockedEvent{
IP: "192.168.1.1",
Comment: "Malicious activity detected",
RequestedURL: "http://mysite.com/admin",
Hostname: "mysite.com",
UserAgent: "BadBot/1.0",
Method: "GET",
}
em.Emit(testEvent)
// Verify that the listener received the event
select {
case receivedEvent := <-testListener.receivedEvents:
if receivedEvent.Name != events.EventBlacklistedIPBlocked {
t.Fatalf("Unexpected event received by listener.\nGot: %+v\nWant: %+v", receivedEvent, testEvent)
}
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for event to be received by listener")
}
// Unregister the listener
if err := em.UnregisterSubscriber(listenerID); err != nil {
t.Fatalf("Failed to unregister subscriber: %v", err)
}
}
func TestEventEmissionToSpecificListener(t *testing.T) {
logger, err := logger.NewFmtLogger()
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}
em := eventManager{
subscriptions: make(map[events.EventName][]ListenerID),
subscribers: make(map[ListenerID]Listener),
logger: logger,
}
// Create a few test listeners
// listener1 and listener2 are plugins that will send events to each other
// moderator is a plugin that is subscribed to EventCustom, so it will receive all custom events
// otherListener is neither subscribed to EventCustom nor a recipient of any custom event, so it should not receive any events
listener1ID := ListenerID("pluginA")
listener1 := &TestListener{
id: listener1ID,
receivedEvents: make(chan events.Event, 10),
}
em.RegisterSubscriberToEvent(listener1, events.EventAccessRuleCreated) // We need to register to some event to be a valid subscriber
listener2ID := ListenerID("pluginB")
listener2 := &TestListener{
id: listener2ID,
receivedEvents: make(chan events.Event, 10),
}
em.RegisterSubscriberToEvent(listener2, events.EventAccessRuleCreated) // We need to register to some event to be a valid subscriber
moderatorID := ListenerID("moderator")
moderator := &TestListener{
id: moderatorID,
receivedEvents: make(chan events.Event, 10),
}
em.RegisterSubscriberToEvent(moderator, events.EventCustom)
otherListenerID := ListenerID("pluginD")
otherListener := &TestListener{
id: otherListenerID,
receivedEvents: make(chan events.Event, 10),
}
em.RegisterSubscriberToEvent(otherListener, events.EventAccessRuleCreated) // We need to register to some event to be a valid subscriber
// Send a custom event from listener1 to listener2
customEvent := &events.CustomEvent{
SourcePlugin: "pluginA",
Recipients: []string{"pluginB"},
Payload: map[string]interface{}{
"message": "Hello from pluginA",
},
}
em.EmitToSubscribersAnd([]ListenerID{listener2ID}, customEvent)
// Verify that listener2 received the event
select {
case receivedEvent := <-listener2.receivedEvents:
if receivedEvent.Name != events.EventCustom {
t.Fatalf("Unexpected event received by listener2.\nGot: %+v\nWant: %+v", receivedEvent, customEvent)
}
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for event to be received by listener2")
}
// Verify that listener1 did not receive the event
select {
case <-listener1.receivedEvents:
t.Fatal("Listener1 should not have received any events")
case <-time.After(100 * time.Millisecond):
// No event received, as expected
}
// send a custom event from listener2 to listener1
customEvent2 := &events.CustomEvent{
SourcePlugin: "pluginB",
Recipients: []string{"pluginA"},
Payload: map[string]interface{}{
"message": "Hello from pluginB",
},
}
em.EmitToSubscribersAnd([]ListenerID{listener1ID}, customEvent2)
// Verify that listener1 received the event
select {
case receivedEvent := <-listener1.receivedEvents:
if receivedEvent.Name != events.EventCustom {
t.Fatalf("Unexpected event received by listener1.\nGot: %+v\nWant: %+v", receivedEvent, customEvent2)
}
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for event to be received by listener1")
}
// Verify that listener2 did not receive any new event
select {
case <-listener2.receivedEvents:
t.Fatal("Listener2 should not have received any new events")
case <-time.After(100 * time.Millisecond):
// No event received, as expected
}
// ensure the moderator received both events
expectedMessagesSeen := map[string]bool{
"Hello from pluginA": false,
"Hello from pluginB": false,
}
for range expectedMessagesSeen {
select {
case receivedEvent := <-moderator.receivedEvents:
if receivedEvent.Name != events.EventCustom {
t.Fatalf("Unexpected event received by moderator.\nGot: %+v\nWant: %+v", receivedEvent, customEvent)
}
data, ok := receivedEvent.Data.(*events.CustomEvent)
if !ok {
t.Fatalf("Unexpected data type in event received by moderator.\nGot: %T\nWant: *events.CustomEvent", receivedEvent.Data)
}
message, ok := data.Payload["message"].(string)
if !ok {
t.Fatalf("Unexpected payload format in event received by moderator.\nGot: %+v\nWant: map with 'message' key", data.Payload)
}
if alreadySeen, exists := expectedMessagesSeen[message]; exists {
if alreadySeen {
t.Fatalf("Duplicate message in event received by moderator: %s", message)
}
expectedMessagesSeen[message] = true
} else {
t.Fatalf("Unexpected message in event received by moderator: %s", message)
}
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for event to be received by moderator")
}
}
for msg, seen := range expectedMessagesSeen {
if !seen {
t.Fatalf("Moderator did not receive expected message: %s", msg)
}
}
// Ensure that the events were not seen by the other listener
select {
case <-otherListener.receivedEvents:
t.Fatal("otherListener should not have received any events")
case <-time.After(100 * time.Millisecond):
// No event received, as expected
}
}
func TestEmissionToNonExistentListener(t *testing.T) {
// Create a test listener and register it
listenerID := ListenerID("testListener")
testListener := &TestListener{
id: listenerID,
receivedEvents: make(chan events.Event, 10),
}
// Create event manager with the test listener marked as subscribed to BlacklistToggledEvents,
// but not actually registered
logger, err := logger.NewFmtLogger()
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}
em := eventManager{
subscriptions: map[events.EventName][]ListenerID{
events.EventBlacklistToggled: {listenerID},
},
subscribers: make(map[ListenerID]Listener),
logger: logger,
}
// Emit a BlacklistToggled event
testEvent := &events.BlacklistToggledEvent{
RuleID: "rule123",
Enabled: false,
}
eventEmitted := make(chan struct{})
go func() {
em.Emit(testEvent)
time.Sleep(10 * time.Millisecond) // Give some time for the emission to process
close(eventEmitted)
}()
// Wait for the event emission to complete
select {
case <-eventEmitted:
case <-time.After(1 * time.Second):
t.Fatal("Timeout waiting for event emission to complete, likely due to deadlock")
}
// check if the listener is still tracked by the event manager
em.mutex.RLock()
_, isRegistered := em.subscribers[listenerID]
subscribers := em.subscriptions[events.EventBlacklistToggled]
em.mutex.RUnlock()
if len(subscribers) != 0 {
t.Fatal("Listener should have been removed from subscriptions after failed dispatch")
}
if isRegistered {
t.Fatal("Listener was somehow registered")
}
// Since the listener was unregistered, it should not receive any events
select {
case <-testListener.receivedEvents:
t.Fatal("Listener should not have received any events after being unregistered")
case <-time.After(100 * time.Millisecond):
// No event received, as expected
}
}

View File

@@ -18,11 +18,15 @@ import (
*/
type Logger struct {
Prefix string //Prefix for log files
LogFolder string //Folder to store the log file
CurrentLogFile string //Current writing filename
logger *log.Logger
file *os.File
Prefix string //Prefix for log files
LogFolder string //Folder to store the log file
CurrentLogFile string //Current writing filename
RotateOption *RotateOption //Options for log rotation, see rotate.go
//Internal
logRotateTicker *time.Ticker
logger *log.Logger
file *os.File
}
// Create a new logger that log to files
@@ -46,6 +50,17 @@ func NewLogger(logFilePrefix string, logFolder string) (*Logger, error) {
thisLogger.CurrentLogFile = logFilePath
thisLogger.file = f
//Initiate the log rotation ticker
thisLogger.logRotateTicker = time.NewTicker(1 * time.Hour)
go func() {
for range thisLogger.logRotateTicker.C {
err := thisLogger.RotateLog()
if err != nil {
log.Println("Log rotation error: ", err.Error())
}
}
}()
//Start the logger
logger := log.New(f, "", log.Flags()&^(log.Ldate|log.Ltime))
logger.SetFlags(0)
@@ -65,6 +80,11 @@ func NewFmtLogger() (*Logger, error) {
}, nil
}
// SetRotateOption will set the log rotation option
func (l *Logger) SetRotateOption(option *RotateOption) {
l.RotateOption = option
}
func (l *Logger) getLogFilepath() string {
year, month, _ := time.Now().Date()
return filepath.Join(l.LogFolder, l.Prefix+"_"+strconv.Itoa(year)+"-"+strconv.Itoa(int(month))+".log")
@@ -118,6 +138,12 @@ func (l *Logger) ValidateAndUpdateLogFilepath() {
l.file.Close()
l.file = nil
//Archive the old log file
err := l.ArchiveLog(l.CurrentLogFile)
if err != nil {
log.Println("Unable to archive old log file: ", err.Error())
}
//Create a new log file
f, err := os.OpenFile(expectedCurrentLogFilepath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0755)
if err != nil {
@@ -135,5 +161,10 @@ func (l *Logger) ValidateAndUpdateLogFilepath() {
}
func (l *Logger) Close() {
l.file.Close()
if l.file != nil {
if err := l.file.Close(); err != nil {
log.Println("Error closing log file:", err)
}
}
l.StopLogRotateTicker()
}

View File

@@ -0,0 +1,204 @@
package logger
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"time"
"imuslab.com/zoraxy/mod/utils"
)
type RotateOption struct {
Enabled bool //Whether log rotation is enabled, default false
MaxSize int64 //Maximum size of the log file in bytes before rotation (e.g. 10 * 1024 * 1024 for 10MB)
MaxBackups int //Maximum number of backup files to keep
Compress bool //Whether to compress the rotated files
BackupDir string //Directory to store backup files, if empty, use the same directory as the log file
}
// Stop the log rotation ticker
func (l *Logger) StopLogRotateTicker() {
if l.logRotateTicker != nil {
l.logRotateTicker.Stop()
}
}
// Check if the log file needs rotation
func (l *Logger) LogNeedRotate(filename string) bool {
if !l.RotateOption.Enabled {
return false
}
info, err := os.Stat(filename)
if err != nil {
return false
}
return info.Size() >= l.RotateOption.MaxSize
}
// Handle web request trigger log ratation
func (l *Logger) HandleDebugTriggerLogRotation(w http.ResponseWriter, r *http.Request) {
err := l.RotateLog()
if err != nil {
utils.SendErrorResponse(w, "Log rotation error: "+err.Error())
return
}
l.PrintAndLog("logger", "Log rotation triggered via REST API", nil)
utils.SendOK(w)
}
// ArchiveLog will archive the given log file, use during month change
func (l *Logger) ArchiveLog(filename string) error {
if l.RotateOption == nil || !l.RotateOption.Enabled {
return nil
}
// Determine backup directory
backupDir := l.RotateOption.BackupDir
if backupDir == "" {
backupDir = filepath.Dir(filename)
}
// Ensure backup directory exists
if err := os.MkdirAll(backupDir, 0755); err != nil {
return err
}
// Generate archived filename with timestamp
timestamp := time.Now().Format("20060102-150405")
baseName := filepath.Base(filename)
baseName = baseName[:len(baseName)-len(filepath.Ext(baseName))]
archivedName := fmt.Sprintf("%s.%s.log", baseName, timestamp)
archivedPath := filepath.Join(backupDir, archivedName)
// Rename current log file to archived file
if err := os.Rename(filename, archivedPath); err != nil {
return err
}
// Optionally compress the archived file
if l.RotateOption.Compress {
if err := compressFile(archivedPath); err != nil {
return err
}
os.Remove(archivedPath)
}
return nil
}
// Execute log rotation
func (l *Logger) RotateLog() error {
if l.RotateOption == nil || !l.RotateOption.Enabled {
return nil
}
needRotate := l.LogNeedRotate(l.CurrentLogFile)
l.PrintAndLog("logger", fmt.Sprintf("Log rotation check: need rotate = %v", needRotate), nil)
if !needRotate {
return nil
}
// Close current file with retry on failure
if l.file != nil {
var closeErr error
for i := 0; i < 5; i++ {
closeErr = l.file.Close()
if closeErr == nil {
break
}
time.Sleep(1 * time.Second)
}
if closeErr != nil {
return closeErr
}
}
// Determine backup directory
backupDir := l.RotateOption.BackupDir
if backupDir == "" {
backupDir = filepath.Dir(l.CurrentLogFile)
}
// Ensure backup directory exists
if err := os.MkdirAll(backupDir, 0755); err != nil {
return err
}
// Generate rotated filename with timestamp
timestamp := time.Now().Format("20060102-150405")
baseName := filepath.Base(l.CurrentLogFile)
baseName = baseName[:len(baseName)-len(filepath.Ext(baseName))]
rotatedName := fmt.Sprintf("%s.%s.log", baseName, timestamp)
rotatedPath := filepath.Join(backupDir, rotatedName)
// Rename current log file to rotated file
if err := os.Rename(l.CurrentLogFile, rotatedPath); err != nil {
return err
}
// Optionally compress the rotated file
if l.RotateOption.Compress {
if err := compressFile(rotatedPath); err != nil {
return err
}
// Remove the uncompressed rotated file after compression
os.Remove(rotatedPath)
rotatedPath += ".gz"
}
// Remove old backups if exceeding MaxBackups
if l.RotateOption.MaxBackups > 0 {
files, err := filepath.Glob(filepath.Join(backupDir, baseName+".*"))
if err == nil && len(files) > l.RotateOption.MaxBackups {
sort.Slice(files, func(i, j int) bool {
return files[i] < files[j]
})
for _, old := range files[:len(files)-l.RotateOption.MaxBackups] {
os.Remove(old)
}
}
}
// Reopen a new log file
file, err := os.OpenFile(l.CurrentLogFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
l.file = file
if l.logger != nil {
l.logger.SetOutput(file)
}
return nil
}
// compressFile compresses the given file using zip format and creates a .gz file.
func compressFile(filename string) error {
zipFilename := filename + ".gz"
outFile, err := os.Create(zipFilename)
if err != nil {
return err
}
defer outFile.Close()
zipWriter := zip.NewWriter(outFile)
defer zipWriter.Close()
fileToCompress, err := os.Open(filename)
if err != nil {
return err
}
defer fileToCompress.Close()
w, err := zipWriter.Create(filepath.Base(filename))
if err != nil {
return err
}
_, err = io.Copy(w, fileToCompress)
return err
}

Some files were not shown because too many files have changed in this diff Show More