136 Commits

Author SHA1 Message Date
Toby Chui
145ccfffb2 Added more features on static cache feature 2025-10-24 20:45:37 +08:00
Toby Chui
d6ad0155ab Init commit for static cache system
Added work in progress static cache system that cache static resources like image on zoraxy node for faster access speed, still experimental and not working
2025-10-24 17:19:09 +08:00
Toby Chui
9d0a2a94f7 Add option to disable request logging per endpoint 2025-10-23 22:41:09 +08:00
Toby Chui
f9ef648664 Fix TLS cert key and pem file handling order 2025-10-22 20:38:19 +08:00
Toby Chui
8f95b622ff Removed redundant error check 2025-10-22 20:09:53 +08:00
Toby Chui
fa941e26a7 Merge pull request #860 from kjagosz/main
- Add PKCE support with S256 challenge method for OAuth2 (fixes #852)
2025-10-22 15:03:22 +08:00
Toby Chui
72e5d3ce3f Fixed #855
- Updated timeout to 600s
- Fixed dns challenge certificate set default bug
2025-10-21 21:22:43 +08:00
kjagosz
7efc7da9ab - Introduce configurable OAuth2 configuration cache time via UI and backend
- Refactor OAuth2Router to use `OAuth2ConfigurationCacheTime` with a default of 60s
2025-10-21 13:04:28 +02:00
kjagosz
944a8651ea Update src/mod/auth/sso/oauth2/oauth2.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 11:59:31 +02:00
kjagosz
f3143e52b3 Update src/mod/auth/sso/oauth2/oauth2.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-21 11:59:03 +02:00
Toby Chui
5c8e4a7df1 Fixed priority sort logic for HTTP proxy rule
Fixed proxy rule sort logic from alphabetical to best match
2025-10-21 07:52:07 +08:00
Toby Chui
0f295185f1 Update src/mod/auth/sso/oauth2/oauth2.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 21:50:39 +08:00
Toby Chui
322e5239a8 Update src/web/components/sso.html
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-20 21:34:31 +08:00
kjagosz
71bf844dc1 - Add PKCE support with S256 challenge method for OAuth2 (fixes #852)
- Update UI for OAuth2 Code Challenge Method selection (closes #854 )
- Introduce OAuth2 configuration cache to optimize requests (fixes #852)
- Fixes using DNS Challange for Custom ACME Server
2025-10-20 14:55:17 +02:00
Toby Chui
298444a53f Merge pull request #858 from tobychui/main
master branch sync
2025-10-19 22:18:48 +08:00
Toby Chui
41cf0cc2c7 Fixed #856
Updated upstreamHostSwap to accept currentTarget and avoid swapping to the same proxy endpoint, preventing unnecessary loopback handling. Also bumped SYSTEM_VERSION to 3.2.9.
2025-10-19 22:11:58 +08:00
Toby Chui
863113a1b8 Merge pull request #853 from Morethanevil/main
Update CHANGELOG.md
2025-10-18 10:17:06 +08:00
Marcel
aa9036fa8b Update CHANGELOG.md 2025-10-17 16:23:35 +02:00
Toby Chui
341f24bcac Merge branch 'main' of https://github.com/tobychui/zoraxy 2025-10-16 21:50:04 +08:00
Toby Chui
af5f835876 Updated build in geoip db 2025-10-16 21:49:30 +08:00
Toby Chui
ece4a39e8c Merge pull request #851 from tobychui/v3.2.8
V3.2.8
2025-10-16 21:48:29 +08:00
Toby Chui
54a18169d7 Added untrust ip get in netutils 2025-10-16 20:13:33 +08:00
Toby Chui
9a5a0eb84d Added user selectable min TLS version
- Added user selectable min TLS version in UI
- Updated api to support custom TLS versions
2025-10-15 20:20:58 +08:00
Toby Chui
deb096545d Update udpprox.go
- Added the missing proper type conversion for UDP proxy protocol version field
2025-10-15 19:38:35 +08:00
Toby Chui
824972a1e2 Added custom type for Proxy Protocol Version
- Changed enum type for proxy protocol
- Added warning for proxy protocol 1 on UDP selection in UI
2025-10-15 07:40:48 +08:00
Toby Chui
0e78f3af65 Merge pull request #848 from jemmy1794/feature/Proxy_Protocol_V2
Add support for Proxy Protocol V1 and V2 in streamproxy configuration
2025-10-15 07:28:44 +08:00
jemmy1794
aa243f23ee Add WriteProxyProtocolHeaderUDP function to support Proxy Protocol v2 for UDP connections 2025-10-14 21:56:11 +08:00
jemmy1794
8d29a929f7 Add support for Proxy Protocol V1 and V2 in streamproxy configuration
- Updated HTML form to allow selection of Proxy Protocol version.
- Backend and config struct now use ProxyProtocolVersion (int) instead of UseProxyProtocol (bool).
- UI and API now pass and display Proxy Protocol version.

TODO: UDP should only allow Proxy Protocol V2
2025-10-14 21:30:32 +08:00
Toby Chui
66572981b3 Optimized TLS list
- Optimized TLS certificate list
- Added common name auto fill for certificate upload tool
- Updated version number
- Removed unused code in helper
2025-10-13 21:09:29 +08:00
Toby Chui
9ed9d9ede4 Merge pull request #847 from zen8841/fix845
Move function:NormalizeDomain to netutils module
2025-10-13 18:35:40 +08:00
Zen Wen
ed1b0ec673 Move function:NormalizeDomain to netutils module 2025-10-13 13:16:20 +08:00
Toby Chui
b22e011131 Merge pull request #846 from zen8841/fix845
Fix #845
2025-10-13 12:48:07 +08:00
Zen Wen
d155ea3795 Linting and formatting 2025-10-13 00:50:28 +08:00
Zen Wen
dd610e5f75 fix #845 2025-10-13 00:23:15 +08:00
Zen Wen
c424b92036 Move function:NormalizeDomain to utils module 2025-10-13 00:16:31 +08:00
Toby Chui
2c270640e9 Added #843
- Added checkbox for disabling uptime monitor in new proxy
2025-10-11 21:36:16 +08:00
Toby Chui
ec91a1986f Merge pull request #841 from Morethanevil/main
Update CHANGELOG.md
2025-10-10 16:18:19 +08:00
Toby Chui
cf2cf18136 Added check for loopback proxy enable state
- Added check and show 521 if the loopback proxy endpoint is disabled
2025-10-10 15:51:00 +08:00
Marcel
de5b95e086 Update CHANGELOG.md
Great work as always :)
2025-10-10 08:54:32 +02:00
Toby Chui
e77f947d1d Added loopback proxy support
- Added support for shortcut loopback setup in local setups
2025-10-10 14:43:38 +08:00
Toby Chui
ca12facaf2 Fixed bug in sidebar plugin list update
- Fixed remove plugin when the plugin is still running but plugin in sidebar not automatically removed bug
2025-10-09 19:14:12 +08:00
Toby Chui
88aba38495 Merge pull request #837 from tobychui/v3.2.7
V3.2.7
2025-10-09 18:43:50 +08:00
PassiveLemon
95375d3298 Update logrotate flag description 2025-10-09 00:08:28 -04:00
Toby Chui
2c3f36d9a3 Merge pull request #829 from jimmyGALLAND/fix-acme-renew
fix acme renew
2025-10-03 07:16:20 +08:00
jimmyGALLAND
030ef2e01c allow domain labels with no minimum length 2025-10-02 12:42:13 +02:00
Toby Chui
85cad1e2b6 Updated #821 2025-09-26 07:12:17 +08:00
Toby Chui
94afb6e3a5 Optimized mobile side menu
- Optimized width for mobile side menu items
2025-09-25 21:11:28 +08:00
Toby Chui
84a4eaaf95 Fixed #821
- Added the recommended code snippet
2025-09-25 21:11:10 +08:00
Toby Chui
f98e1b8218 Fixed #706
- Added conditional injection of x-proxy-by zoraxy header by only injecting tracker when -dev flag is set to true
2025-09-24 21:53:11 +08:00
Toby Chui
778df1af0f Updated #411
- Added support for human readable units in -logrotate flag
2025-09-24 20:31:53 +08:00
jimmyGALLAND
2140e5b0b5 -Add support for including Subject Alternative Names (SANs) from
existing certificates during both manual and automatic renewals.
-Enhance filtering and normalization of domain names from the UI
to ensure only valid domains are included when requesting certificates.
2025-09-23 23:36:49 +02:00
Toby Chui
e9c1d14e23 Removed unused code from PR 2025-09-22 20:07:38 +08:00
Toby Chui
5477822015 Merge pull request #828 from jimmyGALLAND/fix-restart-after-acme-DNS-challenge
Fix restart after acme dns challenge
2025-09-22 20:05:20 +08:00
jimmyGALLAND
b0922c466d fix: restart issue after ACME certificate update with DNS challenge 2025-09-22 01:21:21 +02:00
Toby Chui
1faaae21d7 Merge pull request #827 from Saeraphinx/webui-style-changes
Update Sidebar CSS
2025-09-20 12:32:42 +08:00
Saera
53c73e1e77 update styling 2025-09-19 11:44:20 -05:00
Toby Chui
0805da9d13 Added more test cases for netutil ipmatch 2025-09-19 21:14:20 +08:00
Toby Chui
52f652fbaf Enable SNI offload in HTTPS proxy connections
Updated the ReverseProxy's ProxyHTTPS method to use tls.Dial with SNI support when connecting to upstream servers. Also incremented SYSTEM_VERSION to 3.2.7.
2025-09-17 07:37:21 +08:00
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
0f621d0edd Merge pull request #759 from AnthonyMichaelTDM/issue-758
fix(issue 758): Handle existing symlink in start_zerotier function
2025-07-22 07:10:01 +08:00
Anthony Rubick
c982541a40 fix(issue 758): Handle existing symlink in start_zerotier function
Closes Zoraxy 3.2.5 don't start in the docker container
Fixes #758
2025-07-20 23:39:34 -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
165 changed files with 135420 additions and 128272 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,3 +1,35 @@
# v3.2.8 16 Oct 2025
+ Fixed wildcard certificate bug [#845](https://github.com/tobychui/zoraxy/issues/845) by [zen8841](https://github.com/zen8841)
+ Move function:NormalizeDomain to netutils module by [zen8841](https://github.com/zen8841)
+ Add support for Proxy Protocol V1 and V2 in streamproxy configuration by [jemmy1794](https://github.com/jemmy1794)
+ Added user selectable versions for TLS
# v3.2.7 09 Oct 2025
+ Update Sidebar CSS by [Saeraphinx](https://github.com/Saeraphinx)
+ fix restart after acme dns challenge by [jimmyGALLAND](https://github.com/jimmyGALLAND)
+ fix acme renew by [jimmyGALLAND](https://github.com/jimmyGALLAND)
# v3.2.6 (Prerelease) 16 Sep 2025
+ feat(plugins): Implement plugin API key management and authentication middleware by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ fix: Handle existing symlink in start_zerotier function by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM) [#758](https://github.com/tobychui/zoraxy/issues/758)
+ fix: panics when rewriting headers for websockets, and strange issue with logging across a month boundary by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM) [#771](https://github.com/tobychui/zoraxy/issues/771)
+ add CODEOWNERS file by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ Update lego to v4.25.2 by [zen8841](https://github.com/zen8841)
+ feat(sso): forward auth body and alternate headers by james-d-elliott [#819](https://github.com/tobychui/zoraxy/issues/819)
+ feat(sso): clear settings by [james-d-elliott](https://github.com/james-d-elliott)
+ feat(plugins): Implement event system w/ POC events by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ feature: new container environment vars by [PassiveLemon](https://github.com/PassiveLemon)
+ Update example plugins by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ feat(event system): Flesh out EventPayload interface by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ feat(plugin API): Plugin-to-plugin-comms by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ put plugin API on separate mux not protected by CSRF by [AnthonyMichaelTDM](https://github.com/AnthonyMichaelTDM)
+ fix Enable Lan and Loopback [#799](https://github.com/tobychui/zoraxy/issues/799)
# v3.2.5 20 Jul 2025

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` (String) | Enable log rotation and set the maximum log file size (Supports K, M, G suffixes). Set to 0 to 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

@@ -73,7 +73,10 @@ def start_zerotier():
os.makedirs(config_dir, exist_ok=True)
os.symlink(config_dir, zt_path, target_is_directory=True)
try:
os.symlink(config_dir, zt_path, target_is_directory=True)
except FileExistsError:
print(f"Symlink {zt_path} already exists, skipping creation.")
zerotier_proc = popen(["zerotier-one"])
@@ -84,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)
}
@@ -197,9 +210,10 @@ func handleListBlacklisted(w http.ResponseWriter, r *http.Request) {
}
resulst := []string{}
if bltype == "country" {
switch bltype {
case "country":
resulst = rule.GetAllBlacklistedCountryCode()
} else if bltype == "ip" {
case "ip":
resulst = rule.GetAllBlacklistedIp()
}
@@ -359,6 +373,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 +585,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

@@ -74,11 +74,13 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) {
func RegisterTLSAPIs(authRouter *auth.RouterDef) {
//Global certificate settings
authRouter.HandleFunc("/api/cert/tls", handleToggleTLSProxy)
authRouter.HandleFunc("/api/cert/tlsRequireLatest", handleSetTlsRequireLatest)
authRouter.HandleFunc("/api/cert/tlsMinVersion", handleSetTlsMinVersion)
authRouter.HandleFunc("/api/cert/resolve", handleCertTryResolve)
authRouter.HandleFunc("/api/cert/setPreferredCertificate", handleSetDomainPreferredCertificate)
//Certificate store functions
authRouter.HandleFunc("/api/cert/setDefault", tlsCertManager.SetCertAsDefault)
authRouter.HandleFunc("/api/cert/getCommonName", tlsCertManager.HandleGetCertCommonName)
authRouter.HandleFunc("/api/cert/upload", tlsCertManager.HandleCertUpload)
authRouter.HandleFunc("/api/cert/download", tlsCertManager.HandleCertDownload)
authRouter.HandleFunc("/api/cert/list", tlsCertManager.HandleListCertificate)
@@ -382,7 +384,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

@@ -45,32 +45,49 @@ func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) {
}
}
// Handle the GET and SET of reverse proxy TLS versions
func handleSetTlsRequireLatest(w http.ResponseWriter, r *http.Request) {
newState, err := utils.PostPara(r, "set")
if err != nil {
//GET
var reqLatestTLS bool = false
if sysdb.KeyExists("settings", "forceLatestTLS") {
sysdb.Read("settings", "forceLatestTLS", &reqLatestTLS)
}
js, _ := json.Marshal(reqLatestTLS)
utils.SendJSONResponse(w, string(js))
} else {
switch newState {
case "true":
sysdb.Write("settings", "forceLatestTLS", true)
SystemWideLogger.Println("Updating minimum TLS version to v1.2 or above")
dynamicProxyRouter.UpdateTLSVersion(true)
case "false":
sysdb.Write("settings", "forceLatestTLS", false)
SystemWideLogger.Println("Updating minimum TLS version to v1.0 or above")
dynamicProxyRouter.UpdateTLSVersion(false)
default:
utils.SendErrorResponse(w, "invalid state given")
}
func minTlsVersionStringToUint16(version string) uint16 {
// Update the setting
var tlsVersionUint16 uint16
switch version {
case "1.0":
tlsVersionUint16 = 0x0301
case "1.1":
tlsVersionUint16 = 0x0302
case "1.2":
tlsVersionUint16 = 0x0303
case "1.3":
tlsVersionUint16 = 0x0304
}
return tlsVersionUint16
}
// Handle the GET and SET of reverse proxy minimum TLS version
func handleSetTlsMinVersion(w http.ResponseWriter, r *http.Request) {
newVersion, err := utils.PostPara(r, "set")
if err != nil {
// GET
var minTLSVersion string = "1.2" // Default to 1.2
if sysdb.KeyExists("settings", "minTLSVersion") {
sysdb.Read("settings", "minTLSVersion", &minTLSVersion)
}
js, _ := json.Marshal(minTLSVersion)
utils.SendJSONResponse(w, string(js))
return
}
// Validate input
allowed := map[string]bool{"1.0": true, "1.1": true, "1.2": true, "1.3": true}
if !allowed[newVersion] {
utils.SendErrorResponse(w, "invalid TLS version")
return
}
sysdb.Write("settings", "minTLSVersion", newVersion)
tlsVersionUint16 := minTlsVersionStringToUint16(newVersion)
// Update the setting
SystemWideLogger.PrintAndLog("TLS", "Updating minimum TLS version to v"+newVersion+" or above", nil)
dynamicProxyRouter.SetTlsMinVersion(tlsVersionUint16)
utils.SendOK(w)
}
func handleCertTryResolve(w http.ResponseWriter, r *http.Request) {

View File

@@ -44,7 +44,7 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.2.5"
SYSTEM_VERSION = "3.2.9"
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.String("logrotate", "0", "Enable log rotation and set the maximum log file size in KB, also support K, M, G suffix (e.g. 200M), set to 0 to 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,128 +1,142 @@
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
github.com/gorilla/websocket v1.5.1
github.com/grandcat/zeroconf v1.0.0
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/likexian/whois v1.15.1
github.com/microcosm-cc/bluemonday v1.0.26
github.com/monperrus/crawler-user-agents v1.1.0
github.com/pires/go-proxyproto v0.8.1
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 +145,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 +158,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

@@ -27,6 +27,7 @@ import (
"github.com/go-acme/lego/v4/registration"
"imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/info/logger"
"imuslab.com/zoraxy/mod/netutils"
"imuslab.com/zoraxy/mod/utils"
)
@@ -432,6 +433,21 @@ func (a *ACMEHandler) HandleGetExpiredDomains(w http.ResponseWriter, r *http.Req
// to renew the certificate, and sends a JSON response indicating the result of the renewal process.
func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Request) {
domainPara, err := utils.PostPara(r, "domains")
//Clean each domain
cleanedDomains := []string{}
if domainPara != "" {
for _, d := range strings.Split(domainPara, ",") {
// Apply normalization on each domain
nd, err := netutils.NormalizeDomain(d)
if err != nil {
utils.SendErrorResponse(w, jsonEscape(err.Error()))
return
}
cleanedDomains = append(cleanedDomains, nd)
}
}
if err != nil {
utils.SendErrorResponse(w, jsonEscape(err.Error()))
return
@@ -492,10 +508,8 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
dns = true
}
domains := strings.Split(domainPara, ",")
// Default propagation timeout is 300 seconds
propagationTimeout := 300
// Default propagation timeout is 600 seconds (10 minutes)
propagationTimeout := 600
if dns {
ppgTimeout, err := utils.PostPara(r, "ppgTimeout")
if err == nil {
@@ -511,10 +525,28 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
}
}
//Clean spaces in front or behind each domain
cleanedDomains := []string{}
for _, domain := range domains {
cleanedDomains = append(cleanedDomains, strings.TrimSpace(domain))
// Extract SANs from existing PEM to ensure all domains are included
pemPath := fmt.Sprintf("./conf/certs/%s.pem", filename)
sanDomains, err := ExtractDomainsFromPEM(pemPath)
if err == nil {
// Merge domainPara + SANs
domainSet := map[string]struct{}{}
for _, d := range cleanedDomains {
domainSet[d] = struct{}{}
}
for _, d := range sanDomains {
domainSet[d] = struct{}{}
}
// Rebuild cleanedDomains with all unique domains
cleanedDomains = []string{}
for d := range domainSet {
cleanedDomains = append(cleanedDomains, d)
}
a.Logf("Renewal domains including SANs from PEM: "+strings.Join(cleanedDomains, ","), nil)
} else {
a.Logf("Could not extract SANs from PEM, using domainPara only", err)
}
// Extract DNS servers from the request

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

@@ -397,6 +397,15 @@ func (a *AutoRenewer) renewExpiredDomains(certs []*ExpiredCerts) ([]string, erro
dnsServers = strings.Join(certInfo.DNSServers, ",")
}
// Extract SANs from the existing PEM to ensure all domains are included
sanDomains, errSan := ExtractDomainsFromPEM(expiredCert.Filepath)
if errSan == nil && len(sanDomains) > 0 {
expiredCert.Domains = sanDomains
a.Logf("Using SANs from PEM for renewal: "+strings.Join(sanDomains, ","), nil)
} else {
a.Logf("Could not extract SANs from PEM for "+fileName+", using original domains", errSan)
}
_, err = a.AcmeHandler.ObtainCert(expiredCert.Domains, certName, a.RenewerConfig.Email, certInfo.AcmeName, certInfo.AcmeUrl, certInfo.SkipTLS, certInfo.UseDNS, certInfo.PropTimeout, dnsServers)
if err != nil {
a.Logf("Renew "+fileName+"("+strings.Join(expiredCert.Domains, ",")+") failed", err)

View File

@@ -64,6 +64,20 @@ func ExtractIssuerName(certBytes []byte) (string, error) {
return issuer, nil
}
// ExtractDomainsFromPEM reads a PEM certificate file and returns all SANs
func ExtractDomainsFromPEM(pemFilePath string) ([]string, error) {
certBytes, err := os.ReadFile(pemFilePath)
if err != nil {
return nil, err
}
domains, err := ExtractDomains(certBytes)
if err != nil {
return nil, err
}
return domains, nil
}
// Check if a cert is expired by public key
func CertIsExpired(certBytes []byte) bool {
block, _ := pem.Decode(certBytes)

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

@@ -7,23 +7,33 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/jellydator/ttlcache/v3"
"golang.org/x/oauth2"
"imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/info/logger"
"imuslab.com/zoraxy/mod/utils"
)
const (
// DefaultOAuth2ConfigCacheTime defines the default cache duration for OAuth2 configuration
DefaultOAuth2ConfigCacheTime = 60 * time.Second
)
type OAuth2RouterOptions struct {
OAuth2ServerURL string //The URL of the OAuth 2.0 server server
OAuth2TokenURL string //The URL of the OAuth 2.0 token server
OAuth2ClientId string //The client id for OAuth 2.0 Application
OAuth2ClientSecret string //The client secret for OAuth 2.0 Application
OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server
OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint
OAuth2Scopes string //The scopes for OAuth 2.0 Application
Logger *logger.Logger
Database *database.Database
OAuth2ServerURL string //The URL of the OAuth 2.0 server server
OAuth2TokenURL string //The URL of the OAuth 2.0 token server
OAuth2ClientId string //The client id for OAuth 2.0 Application
OAuth2ClientSecret string //The client secret for OAuth 2.0 Application
OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server
OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint
OAuth2Scopes string //The scopes for OAuth 2.0 Application
OAuth2CodeChallengeMethod string //The authorization code challenge method
OAuth2ConfigurationCacheTime *time.Duration
Logger *logger.Logger
Database *database.Database
OAuth2ConfigCache *ttlcache.Cache[string, *oauth2.Config]
}
type OIDCDiscoveryDocument struct {
@@ -57,95 +67,166 @@ func NewOAuth2Router(options *OAuth2RouterOptions) *OAuth2Router {
options.Database.Read("oauth2", "oauth2ClientId", &options.OAuth2ClientId)
options.Database.Read("oauth2", "oauth2ClientSecret", &options.OAuth2ClientSecret)
options.Database.Read("oauth2", "oauth2UserInfoUrl", &options.OAuth2UserInfoUrl)
options.Database.Read("oauth2", "oauth2CodeChallengeMethod", &options.OAuth2CodeChallengeMethod)
options.Database.Read("oauth2", "oauth2Scopes", &options.OAuth2Scopes)
options.Database.Read("oauth2", "oauth2ConfigurationCacheTime", &options.OAuth2ConfigurationCacheTime)
return &OAuth2Router{
ar := &OAuth2Router{
options: options,
}
if options.OAuth2ConfigurationCacheTime == nil ||
options.OAuth2ConfigurationCacheTime.Seconds() == 0 {
cacheTime := DefaultOAuth2ConfigCacheTime
options.OAuth2ConfigurationCacheTime = &cacheTime
}
options.OAuth2ConfigCache = ttlcache.New[string, *oauth2.Config](
ttlcache.WithTTL[string, *oauth2.Config](*options.OAuth2ConfigurationCacheTime),
)
go options.OAuth2ConfigCache.Start()
return ar
}
// 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,
"oauth2CodeChallengeMethod": ar.options.OAuth2CodeChallengeMethod,
"oauth2ConfigurationCacheTime": ar.options.OAuth2ConfigurationCacheTime.String(),
})
utils.SendJSONResponse(w, string(js))
}
func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *http.Request) {
//Update the settings
var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl, oauth2CodeChallengeMethod string
var oauth2ConfigurationCacheTime *time.Duration
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
}
oauth2CodeChallengeMethod, err = utils.PostPara(r, "oauth2CodeChallengeMethod")
if err != nil {
utils.SendErrorResponse(w, "oauth2CodeChallengeMethod not found")
return
}
oauth2ConfigurationCacheTime, err = utils.PostDuration(r, "oauth2ConfigurationCacheTime")
if err != nil {
utils.SendErrorResponse(w, "oauth2ConfigurationCacheTime 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
ar.options.OAuth2CodeChallengeMethod = oauth2CodeChallengeMethod
ar.options.OAuth2ConfigurationCacheTime = oauth2ConfigurationCacheTime
//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)
ar.options.Database.Write("oauth2", "oauth2CodeChallengeMethod", oauth2CodeChallengeMethod)
ar.options.Database.Write("oauth2", "oauth2ConfigurationCacheTime", oauth2ConfigurationCacheTime)
// Flush caches
ar.options.OAuth2ConfigCache.DeleteAll()
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.OAuth2CodeChallengeMethod = ""
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")
ar.options.Database.Delete("oauth2", "oauth2CodeChallengeMethod")
ar.options.Database.Delete("oauth2", "oauth2ConfigurationCacheTime")
utils.SendOK(w)
}
func (ar *OAuth2Router) fetchOAuth2Configuration(config *oauth2.Config) (*oauth2.Config, error) {
@@ -158,12 +239,10 @@ func (ar *OAuth2Router) fetchOAuth2Configuration(config *oauth2.Config) (*oauth2
return nil, err
} else {
defer resp.Body.Close()
oidcDiscoveryDocument := OIDCDiscoveryDocument{}
if err := json.NewDecoder(resp.Body).Decode(&oidcDiscoveryDocument); err != nil {
return nil, err
}
if len(config.Scopes) == 0 {
config.Scopes = oidcDiscoveryDocument.ScopesSupported
}
@@ -210,14 +289,24 @@ func (ar *OAuth2Router) newOAuth2Conf(redirectUrl string) (*oauth2.Config, error
func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request) error {
const callbackPrefix = "/internal/oauth2"
const tokenCookie = "z-token"
const verifierCookie = "z-verifier"
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
reqUrl := scheme + "://" + r.Host + r.RequestURI
oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix)
if err != nil {
ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err)
oauthConfigCache, _ := ar.options.OAuth2ConfigCache.GetOrSetFunc(r.Host, func() *oauth2.Config {
oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix)
if err != nil {
ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err)
return nil
}
return oauthConfig
})
oauthConfig := oauthConfigCache.Value()
if oauthConfig == nil {
w.WriteHeader(500)
return errors.New("failed to fetch OIDC configuration")
}
@@ -232,26 +321,48 @@ func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request)
state := r.URL.Query().Get("state")
if r.Method == http.MethodGet && strings.HasPrefix(r.RequestURI, callbackPrefix) && code != "" && state != "" {
ctx := context.Background()
token, err := oauthConfig.Exchange(ctx, code)
var authCodeOptions []oauth2.AuthCodeOption
if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" {
verifierCookie, err := r.Cookie(verifierCookie)
if err != nil || verifierCookie.Value == "" {
ar.options.Logger.PrintAndLog("OAuth2Router", "Read OAuth2 verifier cookie failed", err)
w.WriteHeader(401)
return errors.New("unauthorized")
}
authCodeOptions = append(authCodeOptions, oauth2.VerifierOption(verifierCookie.Value))
}
token, err := oauthConfig.Exchange(ctx, code, authCodeOptions...)
if err != nil {
ar.options.Logger.PrintAndLog("OAuth2", "Token exchange failed", err)
w.WriteHeader(401)
return errors.New("unauthorized")
}
if !token.Valid() {
ar.options.Logger.PrintAndLog("OAuth2", "Invalid token", err)
w.WriteHeader(401)
return errors.New("unauthorized")
}
cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/"}
cookieExpiry := token.Expiry
if cookieExpiry.IsZero() || cookieExpiry.Before(time.Now()) {
cookieExpiry = time.Now().Add(time.Hour)
}
cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/", Expires: cookieExpiry}
if scheme == "https" {
cookie.Secure = true
cookie.SameSite = http.SameSiteLaxMode
}
w.Header().Add("Set-Cookie", cookie.String())
if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" {
cookie := http.Cookie{Name: verifierCookie, Value: "", Path: "/", Expires: time.Now().Add(-time.Hour * 1)}
if scheme == "https" {
cookie.Secure = true
cookie.SameSite = http.SameSiteLaxMode
}
w.Header().Add("Set-Cookie", cookie.String())
}
//Fix for #695
location := strings.TrimPrefix(state, "/internal/")
//Check if the location starts with http:// or https://. if yes, this is full URL
@@ -290,7 +401,25 @@ func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request)
}
if unauthorized {
state := url.QueryEscape(reqUrl)
url := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
var url string
if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" {
cookie := http.Cookie{Name: verifierCookie, Value: oauth2.GenerateVerifier(), Path: "/", Expires: time.Now().Add(time.Hour * 1)}
if scheme == "https" {
cookie.Secure = true
cookie.SameSite = http.SameSiteLaxMode
}
w.Header().Add("Set-Cookie", cookie.String())
if ar.options.OAuth2CodeChallengeMethod == "PKCE" {
url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", cookie.Value))
} else {
url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cookie.Value))
}
} else {
url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
}
http.Redirect(w, r, url, http.StatusFound)
return errors.New("unauthorized")

View File

@@ -48,7 +48,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//Check if this is a redirection url
if h.Parent.Option.RedirectRuleTable.IsRedirectable(r) {
statusCode := h.Parent.Option.RedirectRuleTable.HandleRedirect(w, r)
h.Parent.logRequest(r, statusCode != 500, statusCode, "redirect", r.Host, "")
h.Parent.logRequest(r, statusCode != 500, statusCode, "redirect", r.Host, "", nil)
return
}
@@ -70,7 +70,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//Use default rule
ruleID = "default"
}
if h.handleAccessRouting(ruleID, w, r) {
if h.handleAccessRouting(ruleID, w, r, sep) {
//Request handled by subroute
return
}
@@ -79,7 +79,9 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if sep.RequireRateLimit {
err := h.handleRateLimitRouting(w, r, sep)
if err != nil {
h.Parent.Option.Logger.LogHTTPRequest(r, "host", 307, r.Host, "")
if !sep.DisableLogging {
h.Parent.Option.Logger.LogHTTPRequest(r, "host", 307, r.Host, "")
}
return
}
}
@@ -92,7 +94,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
//Plugin routing
if h.Parent.Option.PluginManager != nil && h.Parent.Option.PluginManager.HandleRoute(w, r, sep.Tags) {
//Request handled by subroute
return
@@ -110,7 +111,9 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if potentialProxtEndpoint != nil && !potentialProxtEndpoint.Disabled {
//Missing tailing slash. Redirect to target proxy endpoint
http.Redirect(w, r, r.RequestURI+"/", http.StatusTemporaryRedirect)
h.Parent.Option.Logger.LogHTTPRequest(r, "redirect", 307, r.Host, "")
if !sep.DisableLogging {
h.Parent.Option.Logger.LogHTTPRequest(r, "redirect", 307, r.Host, "")
}
return
}
}
@@ -125,7 +128,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
*/
//Root access control based on default rule
blocked := h.handleAccessRouting("default", w, r)
blocked := h.handleAccessRouting("default", w, r, h.Parent.Root)
if blocked {
return
}
@@ -211,19 +214,19 @@ func (h *ProxyHandler) handleRootRouting(w http.ResponseWriter, r *http.Request)
}
hostname := parsedURL.Hostname()
if hostname == domainOnly {
h.Parent.logRequest(r, false, 500, "root-redirect", domainOnly, "")
h.Parent.logRequest(r, false, 500, "root-redirect", domainOnly, "", h.Parent.Root)
http.Error(w, "Loopback redirects due to invalid settings", 500)
return
}
h.Parent.logRequest(r, false, 307, "root-redirect", domainOnly, "")
h.Parent.logRequest(r, false, 307, "root-redirect", domainOnly, "", h.Parent.Root)
http.Redirect(w, r, redirectTarget, http.StatusTemporaryRedirect)
case DefaultSite_NotFoundPage:
//Serve the not found page, use template if exists
h.serve404PageWithTemplate(w, r)
case DefaultSite_NoResponse:
//No response. Just close the connection
h.Parent.logRequest(r, false, 444, "root-no_resp", domainOnly, "")
h.Parent.logRequest(r, false, 444, "root-no_resp", domainOnly, "", h.Parent.Root)
hijacker, ok := w.(http.Hijacker)
if !ok {
w.WriteHeader(http.StatusNoContent)
@@ -237,11 +240,11 @@ func (h *ProxyHandler) handleRootRouting(w http.ResponseWriter, r *http.Request)
conn.Close()
case DefaultSite_TeaPot:
//I'm a teapot
h.Parent.logRequest(r, false, 418, "root-teapot", domainOnly, "")
h.Parent.logRequest(r, false, 418, "root-teapot", domainOnly, "", h.Parent.Root)
http.Error(w, "I'm a teapot", http.StatusTeapot)
default:
//Unknown routing option. Send empty response
h.Parent.logRequest(r, false, 544, "root-unknown", domainOnly, "")
h.Parent.logRequest(r, false, 544, "root-unknown", domainOnly, "", h.Parent.Root)
http.Error(w, "544 - No Route Defined", 544)
}
}

View File

@@ -6,12 +6,14 @@ 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)
// if the return value is false, you can continue process the response writer
func (h *ProxyHandler) handleAccessRouting(ruleID string, w http.ResponseWriter, r *http.Request) bool {
func (h *ProxyHandler) handleAccessRouting(ruleID string, w http.ResponseWriter, r *http.Request, sep *ProxyEndpoint) bool {
accessRule, err := h.Parent.Option.AccessController.GetAccessRuleByID(ruleID)
if err != nil {
//Unable to load access rule. Target rule not found?
@@ -23,7 +25,7 @@ func (h *ProxyHandler) handleAccessRouting(ruleID string, w http.ResponseWriter,
isBlocked, blockedReason := accessRequestBlocked(accessRule, h.Parent.Option.WebDirectory, w, r)
if isBlocked {
h.Parent.logRequest(r, false, 403, blockedReason, r.Host, "")
h.Parent.logRequest(r, false, 403, blockedReason, r.Host, "", sep)
}
return isBlocked
}
@@ -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
}
}
}

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