Zoraxy
--
Beyond Reverse Proxy: Your Ultimate Homelab Network Tool
+
+ - Learn More -
-
| Quick Access | -|
|---|---|
-
-
- Prebuild Binary
-
- |
- - Open - | -
-
-
- Source Code
-
- |
- - Open - | -
This site is currently under development. Some information might not be ready. + // 本網站目前仍在開發中,部分資訊可能尚未準備好。 + // Diese Seite ist in Entwicklung. Einige Informationen sind möglicherweise nicht verfügbar. +
+Zoraxy
+ +The ultimate homelab networking toolbox for self-hosted services + // 簡化自家伺服器部署之事,初學者居家網絡必備良器 + // Das ultimative Homelab-Netzwerk-Toolbox für selbstgehostete Dienste +
+ Download // 立即下載 // Herunterladen + Source Code // 查看原始碼 // Quellcode + +
-
-
- Features
- Highlighting a few important features of Zoraxy
-
-
- -
-
-
- Reverse Proxy
-
-
- Simple to use noob-friendly reverse proxy server that can be easily set up using a web form and a few toggle switches.
-
-
-
- Redirection
-
-
- Direct and intuitive redirection rules with basic rewrite options. Suitable for most simple use cases.
-
-
-
- Geo-IP & Blacklist
-
-
- Blacklist with GeoIP support. Allows easy setup for regional services.
-
-
-
- Global Area Network
-
-
- ZeroTier controller integrated GAN. Enable unlimited nodes in your network with a few clicks.
-
-
-
- Web SSH
-
-
- Integration with Gotty Web SSH terminal allows one-stop management of your nodes inside private LAN via gateway nodes.
-
-
-
- Real Time Statistics
-
-
- Traffic data collection and real-time analytic tools provide you the best insight of visitors data without cookies.
-
-
-
- Scanner & Utilities
-
-
- Build in IP scanner and mDNS discovery service to enable automatic service discovery within LAN.
-
-
-
- Open Source
-
-
- Project is open-source under AGPL on Github. Feel free to contribute on missing functions you need!
-+ +
-
-
-
- Plugins
- Add custom routing rules via simple scripts
-
-
- -
Documentation work in progress
--
-
-
- Source Code
- Feel free to give us a ⭐ star ⭐.
-
-
- -
-
-
-
- Github
- https://github.com/tobychui/zoraxy
-
+
+
+ Reverse Proxy // 反向代理 // Reverse-Proxy
+ Easy setups with dynamic updates // 讓你想不到般簡單易用、迅速設定、動態更新 // Einfache Einrichtung mit dynamischen Updates
+ Access your reverse proxy and self-hosted services from any computer with a browser, anytime, anywhere.
+ // 透過瀏覽器,隨時隨地在任何裝置上存取您的反向代理及自家伺服器服務。
+ // Greifen Sie jederzeit und überall von jedem Gerät aus auf Ihren Reverse-Proxy und selbst gehostete
+
+
+
+
+
+ Simple setups with web UI
+ // 透過網頁介面簡單設定即可使用
+ // Einfache Einrichtung mit Web-UI
+
+
+
+
+
+ Change settings on the fly without restarting
+ // 即時更改設定,無需重新啟動
+ // Einstellungen ohne Neustart ändern
+
+
+
+
+
+ One of the best reverse proxy manager for beginners
+ // 可能是最適合初學者的反向代理管理器之一
+ // Einer der besten Reverse-Proxy-Manager für Anfänger
+
+
+
+
+
+ Easily install plugins and edit configurations
+ // 輕鬆安裝插件並編輯設定
+ // Plugins einfach installieren und Konfigurationen bearbeiten
+
+
-
-
-
-
-
-
-
-
+
-
Reverse Proxy // 反向代理 // Reverse-Proxy
+Easy setups with dynamic updates // 讓你想不到般簡單易用、迅速設定、動態更新 // Einfache Einrichtung mit dynamischen Updates
+Access your reverse proxy and self-hosted services from any computer with a browser, anytime, anywhere. + // 透過瀏覽器,隨時隨地在任何裝置上存取您的反向代理及自家伺服器服務。 + // Greifen Sie jederzeit und überall von jedem Gerät aus auf Ihren Reverse-Proxy und selbst gehostete +
+- - -
--
CopyRight Zoraxy Project and its authors © 2021 -
+
+ Real-time Analytics // 即時流量分析 // Echtzeit-Analysen
+Dynamic statistic and access control // 動態流量數據、權限與路由設定 // Dynamische Statistik und Zugriffskontrolle
+Provide real time statistical overview, take advantage of the real time traffic and situations to make better decisions. + // 提供即時統計概覽,利用即時流量和情況做出更好的決策。 + // Bietet eine Echtzeit-Übersicht über die Statistiken, um bessere Entscheidungen zu treffen. +
++ + +
Screenshots + // 系統截圖 + // Bildschirmfotos +
+
+
+
+
+
+
+
+
+
+ -
+ + +
+
+ Review Videos + // 介紹影片 + // Videos +
++
+
+ Download + // 下載 + // Herunterladen +
++ Install with command line + // 使用 CLI 下載並執行發行版本 + // Installieren Sie mit der Befehlszeile +
+
+ wget https://github.com/tobychui/zoraxy/releases/latest/download/zoraxy_linux_amd64
+ chmod +x ./zoraxy_linux_amd64
+ sudo ./zoraxy_linux_amd64
+
+ +
+ Install with precompiled binary + // 下載發行版本 + // Installieren Sie mit vorkompilierten Binärdateien +
+ + OR + ++ Install with precompiled binary + // 下載發行版本 + // Installieren Sie mit vorkompilierten Binärdateien +
+ ++
Install with command line (armv6-7, arm64, x86) + // 使用 CLI 下載並執行 (armv6-7, arm64, x86) + // Installieren Sie mit der Befehlszeile (armv6-7, arm64, x86) +
+
+ # Check your CPU architecture
+ uname -m
+
+ # For arm64 (aarch64) CPU
+ wget -O zoraxy https://github.com/tobychui/zoraxy/releases/latest/download/zoraxy_linux_arm64
+
+ # For armv6 (armv6l) / armv7 (armv7l) CPU
+ wget -O zoraxy https://github.com/tobychui/zoraxy/releases/latest/download/zoraxy_linux_arm
+
+ # For RISC-V (riscv64) CPU
+ wget -O zoraxy https://github.com/tobychui/zoraxy/releases/latest/download/zoraxy_linux_riscv64
+
+
+ chmod +x ./zoraxy
+ sudo ./zoraxy
+
+ +
Install with precompiled binary + // 下載發行版本 + // Installieren Sie mit vorkompilierten Binärdateien +
+ + + +Require Go (Golang) compiler. Details build from source instruction can be found on Zoraxy Github README file. + // 需要 Go (Go 語言)編譯器。建置詳情可以在 Zoraxy Github README 檔案中找到。 + // Erfordert den Go (Golang) Compiler. Detaillierte Anweisungen zum Erstellen aus dem Quellcode finden Sie in der Zoraxy Github README-Datei. +
+
+ git clone https://github.com/tobychui/zoraxy
+ cd ./zoraxy/src/
+ go mod tidy
+ go build
+ sudo ./zoraxy
+
+ + After Zoraxy is started, navigate to + // 當 Zoraxy 執行檔 / 服務啟動後,使用瀏覽器開啟 + // Nachdem Zoraxy gestartet wurde, navigieren Sie zu + + http://localhost:8000 + to continue account and system setup. + // 以繼續帳戶和系統設定。 + // um die Konto- und Systemeinrichtung fortzusetzen. + +
++
Learn More + // 了解更多 + // Mehr erfahren +
+If you like this project, please feel free to give us a ⭐ star ⭐. + // 如果您喜歡這個開源專案,歡迎來給我們一顆 ⭐星星⭐ 喔!! + // Wenn Ihnen dieses Projekt gefällt, geben Sie uns bitte einen ⭐ Stern ⭐. +
++
+
+
Zoraxy
++
Beyond Reverse Proxy: Your Ultimate Homelab Network Tool
++ Learn More +
+
| Quick Access | +|
|---|---|
+
+
+ Prebuild Binary
+
+ |
+ + Open + | +
+
+
+ Source Code
+
+ |
+ + Open + | +
+
+
+ Features
+ Highlighting a few important features of Zoraxy
+
+
+ +
+
+
+ Reverse Proxy
+
+
+ Simple to use noob-friendly reverse proxy server that can be easily set up using a web form and a few toggle switches.
+
+
+
+ Redirection
+
+
+ Direct and intuitive redirection rules with basic rewrite options. Suitable for most simple use cases.
+
+
+
+ Geo-IP & Blacklist
+
+
+ Blacklist with GeoIP support. Allows easy setup for regional services.
+
+
+
+ Global Area Network
+
+
+ ZeroTier controller integrated GAN. Enable unlimited nodes in your network with a few clicks.
+
+
+
+ Web SSH
+
+
+ Integration with Gotty Web SSH terminal allows one-stop management of your nodes inside private LAN via gateway nodes.
+
+
+
+ Real Time Statistics
+
+
+ Traffic data collection and real-time analytic tools provide you the best insight of visitors data without cookies.
+
+
+
+ Scanner & Utilities
+
+
+ Build in IP scanner and mDNS discovery service to enable automatic service discovery within LAN.
+
+
+
+ Open Source
+
+
+ Project is open-source under AGPL on Github. Feel free to contribute on missing functions you need!
++
+
+
+ Plugins
+ Add custom routing rules via simple scripts
+
+
+ +
Documentation work in progress
++
+
+
+ Source Code
+ Feel free to give us a ⭐ star ⭐.
+
+
+ +
+ + +
++ + +
++
CopyRight Zoraxy Project and its authors © 2021 -
++
+ + + diff --git a/docs/main.css b/docs/main.css new file mode 100644 index 0000000..f2e6e18 --- /dev/null +++ b/docs/main.css @@ -0,0 +1,434 @@ +/* Global */ + +p,a,div,span,h1,h2,h3,h4,h5,h6{ + font-family: 'Source Sans Pro', sans-serif; +} + +body.en *:not(i){ + font-family: 'Source Sans Pro', 'Noto Sans TC',sans-serif !important; +} + +body.zh *:not(i){ + font-family: 'Noto Sans TC',sans-serif !important; +} + +body.jp *:not(i){ + font-family: "Noto Sans JP", sans-serif !important; +} + +body.zh-cn *:not(i){ + font-family: 'Noto Sans SC',sans-serif !important; +} + + +.centered.title{ + padding: 2em; + margin-bottom: 2em; + text-align: center; +} +.centered.title h1{ + font-weight: 300 !important; +} + +.messageBanner{ + width: 100%; + background: #6cacff; + text-align: center; + color: white; + padding: 10px; +} +.messageBanner .header{ + font-weight: 500; +} + +#backToTopBtn{ + position: fixed; + bottom: 1em; + right: 1em; + display:none; + z-index: 999; + border: 1px solid white; + background: #6cacff; +} + +#backToTopBtn:hover{ + opacity: 0.8; +} + +#backToTopBtn i{ + color: white; +} + +/* Main Menu */ +#mainmenu{ + padding-top: 0.4em; + padding-bottom: 0.4em; + border-radius: 0; + margin-bottom: 0; + margin-top: 0; +} + +#slideshowBanner .ui.basic.white.button{ + color: white; + box-shadow: 0 0 0 1px rgb(231, 231, 231) inset; + border-radius: 0.4em; + background: none !important; +} +#slideshowBanner .ui.basic.white.button:hover{ + background-color: rgba(255, 255, 255, 0.3) !important; +} + +#slideshowBanner .ui.basic.white.button:active{ + background: rgba(255, 255, 255, 0.5) !important; +} + +#rwdmenubtn{ + display:none; + position: absolute; +} + +#mainmenu .ui.secondary.inverted.menu .link.item:not(.disabled), .ui.secondary.inverted.menu a.item:not(.disabled){ + font-size: 1.1em; + font-weight: 500; + border-bottom: 1px solid transparent; + transition: border-bottom ease-in-out 0.1s; + color: white !important; + border-radius: 0; +} + +#mainmenu #mainmenu .ui.secondary.inverted.menu .link.item:not(.disabled), .ui.secondary.inverted.menu a.item:not(.disabled):hover{ + background-color: transparent; + border-bottom: 1px solid #82adfc; + color: #82adfc !important; +} + +/* Image Sldiers */ +#slideshowBanner{ + background: rgb(108,172,255); + background: linear-gradient(48deg, rgba(108,172,255,1) 8%, rgba(141,235,255,1) 65%); + position: relative; + height: 80vh; +} + +.slideshow { + width: 100%; + overflow: hidden; + border-radius: 0; + max-height: 500px; +} + +.slideshow .slides { + display: flex; + transition: transform 1s ease-in-out; + opacity: 0.6; + filter: blur(2px); + pointer-events: none; + user-select: none; +} + +.slideshow .slide { + min-width: 100%; + box-sizing: border-box; +} + +.slideshow .slide img { + width: 100%; + display: block; +} + +.slideshow .dots{ + text-align: center; + position: absolute; + bottom: 15px; + width: 100%; +} + +.slideshow .dot { + display: inline-block; + width: 10px; + height: 10px; + margin: 0 5px; + background-color: #bebebe; + border-radius: 50%; + cursor: pointer; + transition: background-color 0.6s ease; +} + +.dot.active { + background-color: #ffffff; +} + +#slideshowBanner .title{ + display: inline-block; + width: 100%; + max-width: 500px; + text-align: left; + position: absolute; + top: 50%; + left: 10%; + transform: translateX(0%) translateY(-50%); + color: white; +} + +#slideshowBanner .title .scrolldownTips{ + display: none; +} + +#slideshowBanner .title h1{ + font-size: 4em; + font-weight: 600; + margin-bottom: 0; +} + +#slideshowBanner .title p{ + font-size: 1.2em; +} + +/* About Zoraxy */ +.about-text-wrapper{ + margin-top: 3em; +} +.about-text-wrapper p, .about-text-wrapper .list .item{ + font-weight: 300; +} +.about-title{ + font-size: 2.4em; + font-weight: 300; + margin-bottom: 0em; +} +.about-title b{ + font-weight: 800; +} +.about-text-wrapper .ui.list .item{ + margin-bottom: 0.6em; +} +.about-text-wrapper .ui.list .item .icon{ + padding-top: 0.15em; +} + +/* Screenshots */ +#features{ + margin-bottom: 3em; +} + +#features .screenshot{ + transition: opacity 0.1s ease-in-out; + cursor: pointer; +} + +#features .screenshot:hover{ + opacity: 0.5; +} + +/* Videos */ +#techspec .centered.title{ + color: white; +} + +#techspec p { + color: white; +} + +#techspec .videoScrollBar{ + overflow-x: scroll; + display: block; + white-space: nowrap; + scrollbar-color: #e7e7e7 rgba(0, 0, 0, 0.1); + padding-top: 2em; + padding-bottom: 3em; +} + +.introvideo{ + display: inline-block !important; + +} + +.blackbanner{ + width: 100%; + background: rgb(108,172,255); + background: linear-gradient(48deg, rgba(108,172,255,1) 8%, rgba(141,235,255,1) 65%); + min-height: 300px; + +} + +/* Download */ +.downloadButton { + margin-top: 0.4em !important; +} + +.downloadTabWrapper{ + width: 100%; + overflow-x: hidden; +} + +#download .ui.black.message{ + word-break: break-all; +} + +/* Learn More */ +#learnmore .linkicons{ + text-align: center; + width: 100%; +} + +#learnmore .linkicons .divider{ + margin-left: 1em; + margin-right: 1em; +} + +#learnmore .linkicons .externallink{ + margin-bottom: 0.6em; + transition: opacity 0.1s ease-in-out; +} + +#learnmore .linkicons .externallink i{ + /* color: #1b1c1d; */ + font-weight: 300; + font-size: 1.5em; +} + +#learnmore .linkicons .externallink:hover{ + opacity: 0.8; +} + + +#learnmore .linkicons .externallink .content{ + color: #1b1c1d; + font-weight: 500; + font-size: 0.6em; +} + + +/* Footer */ +#footer{ + background: rgb(85,131,238); + background: linear-gradient(48deg, rgba(85,131,238,1) 21%, rgba(108,172,255,1) 73%); + color: rgb(255, 255, 255); +} + +#footer a { + color: rgb(209, 224, 255); +} + +#footer a:hover{ + color: rgb(255, 255, 255); +} + +#footer .bottom-attach .divider{ + color: rgb(212, 212, 212); +} + +#footer .ui.list .title{ + margin-bottom: 0.6em; +} + +/* RWD Rules */ +@media (max-width:960px) { + /* Main menu */ + #mainmenu{ + display:none; + z-index: 99; + position: absolute; + top: 0; + left: 0; + width: 100%; + } + + #rwdmenubtn{ + display: block; + position: absolute; + top: 0.4em; + right: 0.4em; + z-index: 100; + } + + /* Slideshows */ + .slideshow { + min-height: 100vh; + } + + .slideshow .slide{ + height: 100% !important; + min-width: none; + } + + .slideshow .slide img{ + height: 100%; + width: auto; + } + + #slideshowBanner .title .scrolldownTips{ + margin-top: 2em; + display: block; + } + + #slideshowBanner .title .scrolldownTips img{ + left: 50%; + transform: translateX(-50%); + } + + #download .stackable.tabular.menu .active.item{ + background-color: rgb(243, 243, 243); + border-width: 0; + border-radius: 0.4em !important; + } + +} + + + +/* + Waves CSS +*/ + +#wavesWrapper{ + position: absolute; + bottom: 0; + width: 100%; + left: 0; +} + +.waves { + position:relative; + width: 100%; + height:15vh; + margin-bottom:-7px; /*Fix for safari gap*/ + min-height:100px; + max-height:150px; +} + + +.parallax > use { + animation: move-forever 25s cubic-bezier(.55,.5,.45,.5) infinite; +} +.parallax > use:nth-child(1) { + animation-delay: -8s; + animation-duration: 28s; +} +.parallax > use:nth-child(2) { + animation-delay: -12s; + animation-duration: 40s; +} +.parallax > use:nth-child(3) { + animation-delay: -16s; + animation-duration: 52s; +} +.parallax > use:nth-child(4) { + animation-delay: -20s; + animation-duration: 80s; +} +@keyframes move-forever { + 0% { + transform: translate3d(-90px,0,0); + } + 100% { + transform: translate3d(85px,0,0); + } +} +/*Shrinking for mobile*/ +@media (max-width: 768px) { + .waves { + height:40px; + min-height:40px; + } +} \ No newline at end of file diff --git a/docs/main.js b/docs/main.js new file mode 100644 index 0000000..d8c57ce --- /dev/null +++ b/docs/main.js @@ -0,0 +1,81 @@ +/* + Localization + + To add more locales, add to the html file with // (translated text) + after each DOM elements with attr i18n + + And then add the language ISO key to the list below. +*/ +let languages = ['en', 'zh', 'de']; + + +//Bind language change dropdown events +$(".dropdown").dropdown(); +$("#language").on("change",function(){ + let newLang = $("#language").parent().dropdown("get value"); + i18n.changeLanguage(newLang); + $("body").attr("class", newLang); +}); + +//Initialize the i18n dom library +var i18n = domI18n({ + selector: '[i18n]', + separator: ' // ', + languages: languages, + defaultLanguage: 'en' +}); + +let userLang = navigator.language || navigator.userLanguage; +console.log("User language: " + userLang); +userLang = userLang.split("-")[0]; +if (!languages.includes(userLang)) { + userLang = 'en'; +} +i18n.changeLanguage(userLang); + + +/* Main Menu */ +$("#rwdmenubtn").on("click", function(){ + $("#mainmenu").slideToggle("fast"); +}) + +//Handle resize +$(window).on("resize", function(){ + if (window.innerWidth > 960){ + $("#mainmenu").show(); + }else{ + $("#mainmenu").hide(); + } +}) + +/* + Download +*/ + +$('.menu .item').tab(); + +//Download webpack and binary at the same time +function handleDownload(releasename){ + let binaryURL = "https://github.com/tobychui/zoraxy/releases/latest/download/" + releasename; + window.open(binaryURL); +} + +/* RWD */ +window.addEventListener('scroll', function() { + var scrollPosition = window.scrollY || window.pageYOffset; + var windowHeight = window.innerHeight; + var hiddenDiv = document.querySelector('#backToTopBtn'); + + if (scrollPosition > windowHeight / 2) { + hiddenDiv.style.display = 'block'; + } else { + hiddenDiv.style.display = 'none'; + } +}); + + +function backToTop(){ + $('html, body').animate({scrollTop : 0},800, function(){ + window.location.hash = ""; + }); +} \ No newline at end of file diff --git a/example/plugins/build_all.sh b/example/plugins/build_all.sh index 76d3792..7fabafb 100644 --- a/example/plugins/build_all.sh +++ b/example/plugins/build_all.sh @@ -1,6 +1,16 @@ #!/bin/bash +# This script builds all the plugins in the current directory + +echo "Copying zoraxy_plugin to all mods" +for dir in ./*; do + if [ -d "$dir" ]; then + cp -r ../mod/plugins/zoraxy_plugin "$dir/mod" + fi +done + # Iterate over all directories in the current directory +echo "Running go mod tidy and go build for all directories" for dir in */; do if [ -d "$dir" ]; then echo "Processing directory: $dir" @@ -19,4 +29,4 @@ for dir in */; do fi done -echo "Build process completed for all directories." \ No newline at end of file +echo "Build process completed for all directories." diff --git a/example/plugins/debugger/main.go b/example/plugins/debugger/main.go index 4e1b15d..a5c3b2d 100644 --- a/example/plugins/debugger/main.go +++ b/example/plugins/debugger/main.go @@ -3,14 +3,17 @@ package main import ( "fmt" "net/http" + "sort" "strconv" + "strings" plugin "aroz.org/zoraxy/debugger/mod/zoraxy_plugin" ) const ( - PLUGIN_ID = "org.aroz.zoraxy.debugger" - UI_PATH = "/debug" + PLUGIN_ID = "org.aroz.zoraxy.debugger" + UI_PATH = "/debug" + STATIC_CAPTURE_INGRESS = "/s_capture" ) func main() { @@ -28,15 +31,18 @@ func main() { VersionMinor: 0, VersionPatch: 0, - GlobalCapturePaths: []plugin.CaptureRule{ + StaticCapturePaths: []plugin.StaticCaptureRule{ { - CapturePath: "/debug_test", //Capture all traffic of all HTTP proxy rule - IncludeSubPaths: true, + CapturePath: "/test_a", + }, + { + CapturePath: "/test_b", }, }, - GlobalCaptureIngress: "", - AlwaysCapturePaths: []plugin.CaptureRule{}, - AlwaysCaptureIngress: "", + StaticCaptureIngress: "/s_capture", + + DynamicCaptureSniff: "/d_sniff", + DynamicCaptureIngress: "/d_capture", UIPath: UI_PATH, @@ -50,21 +56,85 @@ func main() { panic(err) } - // Register the shutdown handler - plugin.RegisterShutdownHandler(func() { - // Do cleanup here if needed - fmt.Println("Debugger Terminated") + // Setup the path router + pathRouter := plugin.NewPathRouter() + pathRouter.SetDebugPrintMode(true) + + /* + Static Routers + */ + pathRouter.RegisterPathHandler("/test_a", http.HandlerFunc(HandleCaptureA)) + pathRouter.RegisterPathHandler("/test_b", http.HandlerFunc(HandleCaptureB)) + pathRouter.SetDefaultHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //In theory this should never be called + //but just in case the request is not captured by the path handlers + //this will be the fallback handler + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("This request is captured by the default handler!
Request URI: " + r.URL.String())) + })) + pathRouter.RegisterStaticCaptureHandle(STATIC_CAPTURE_INGRESS, http.DefaultServeMux) + + /* + Dynamic Captures + */ + pathRouter.RegisterDynamicSniffHandler("/d_sniff", http.DefaultServeMux, func(dsfr *plugin.DynamicSniffForwardRequest) plugin.SniffResult { + //fmt.Println("Dynamic Capture Sniffed Request:") + //fmt.Println("Request URI: " + dsfr.RequestURI) + + //In this example, we want to capture all URI + //that start with /test_ and forward it to the dynamic capture handler + if strings.HasPrefix(dsfr.RequestURI, "/test_") { + reqUUID := dsfr.GetRequestUUID() + fmt.Println("Accepting request with UUID: " + reqUUID) + return plugin.SniffResultAccpet + } + + return plugin.SniffResultSkip + }) + pathRouter.RegisterDynamicCaptureHandle("/d_capture", http.DefaultServeMux, func(w http.ResponseWriter, r *http.Request) { + // This is the dynamic capture handler where it actually captures and handle the request + w.WriteHeader(http.StatusOK) + w.Write([]byte("Welcome to the dynamic capture handler!")) + + // Print all the request info to the response writer + w.Write([]byte("\n\nRequest Info:\n")) + w.Write([]byte("Request URI: " + r.RequestURI + "\n")) + w.Write([]byte("Request Method: " + r.Method + "\n")) + w.Write([]byte("Request Headers:\n")) + headers := make([]string, 0, len(r.Header)) + for key := range r.Header { + headers = append(headers, key) + } + sort.Strings(headers) + for _, key := range headers { + for _, value := range r.Header[key] { + w.Write([]byte(fmt.Sprintf("%s: %s\n", key, value))) + } + } }) http.HandleFunc(UI_PATH+"/", RenderDebugUI) - http.HandleFunc("/gcapture", HandleIngressCapture) fmt.Println("Debugger started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port)) http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) } // Handle the captured request -func HandleIngressCapture(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "Capture request received") +func HandleCaptureA(w http.ResponseWriter, r *http.Request) { + /*for key, values := range r.Header { + for _, value := range values { + fmt.Printf("%s: %s\n", key, value) + } + }*/ w.Header().Set("Content-Type", "text/html") - w.Write([]byte("This request is captured by the debugger")) + w.Write([]byte("This request is captured by A handler!
Request URI: " + r.URL.String())) +} + +func HandleCaptureB(w http.ResponseWriter, r *http.Request) { + /*for key, values := range r.Header { + for _, value := range values { + fmt.Printf("%s: %s\n", key, value) + } + }*/ + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("This request is captured by the B handler!
Request URI: " + r.URL.String())) } diff --git a/example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go b/example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go new file mode 100644 index 0000000..9bed106 --- /dev/null +++ b/example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go @@ -0,0 +1,145 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" +) + +type PluginUiDebugRouter struct { + PluginID string //The ID of the plugin + TargetDir string //The directory where the UI files are stored + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + EnableDebug bool //Enable debug mode + terminateHandler func() //The handler to be called when the plugin is terminated +} + +// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system +// The targetDir is the directory where the UI files are stored (e.g. ./www) +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiDebugRouter{ + PluginID: pluginID, + TargetDir: targetDir, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from file system + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + //Check if the request is for a directory + //Check if the directory has an index.html file + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html" + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + if _, err := os.Stat(targetFilePath); err == nil { + //Serve the index.html file + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiDebugRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + if p.EnableDebug { + fmt.Print("Request URL:", r.URL.Path, " rewriting to ") + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL.Path = rewrittenURL + r.RequestURI = rewrittenURL + if p.EnableDebug { + fmt.Println(r.URL.Path) + } + + //Serve the file from the file system + fsHandler := http.FileServer(http.Dir(p.TargetDir)) + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the file system UI handler to the target http.ServeMux +func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/debugger/mod/zoraxy_plugin/dynamic_router.go b/example/plugins/debugger/mod/zoraxy_plugin/dynamic_router.go new file mode 100644 index 0000000..1dc53ce --- /dev/null +++ b/example/plugins/debugger/mod/zoraxy_plugin/dynamic_router.go @@ -0,0 +1,162 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +/* + + Dynamic Path Handler + +*/ + +type SniffResult int + +const ( + SniffResultAccpet 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 == SniffResultAccpet { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else { + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("SKIP")) + } + })) +} + +// RegisterDynamicCaptureHandle register the dynamic capture ingress path with a handler +func (p *PathRouter) RegisterDynamicCaptureHandle(capture_ingress string, mux *http.ServeMux, handlefunc func(http.ResponseWriter, *http.Request)) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p.enableDebugPrint { + fmt.Println("Request captured by dynamic capture path: " + r.RequestURI) + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, capture_ingress) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + if rewrittenURL == "" { + rewrittenURL = "/" + } + if !strings.HasPrefix(rewrittenURL, "/") { + rewrittenURL = "/" + rewrittenURL + } + r.RequestURI = rewrittenURL + + handlefunc(w, r) + })) +} + +/* + Sniffing and forwarding + + The following functions are here to help with + sniffing and forwarding requests to the dynamic + router. +*/ +// A custom request object to be used in the dynamic sniffing +type DynamicSniffForwardRequest struct { + Method string `json:"method"` + Hostname string `json:"hostname"` + URL string `json:"url"` + Header map[string][]string `json:"header"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + RequestURI string `json:"request_uri"` + Proto string `json:"proto"` + ProtoMajor int `json:"proto_major"` + ProtoMinor int `json:"proto_minor"` + + /* Internal use */ + rawRequest *http.Request `json:"-"` + requestUUID string `json:"-"` +} + +// GetForwardRequestPayload returns a DynamicSniffForwardRequest object from an http.Request object +func EncodeForwardRequestPayload(r *http.Request) DynamicSniffForwardRequest { + return DynamicSniffForwardRequest{ + Method: r.Method, + Hostname: r.Host, + URL: r.URL.String(), + Header: r.Header, + RemoteAddr: r.RemoteAddr, + Host: r.Host, + RequestURI: r.RequestURI, + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + rawRequest: r, + } +} + +// DecodeForwardRequestPayload decodes JSON bytes into a DynamicSniffForwardRequest object +func DecodeForwardRequestPayload(jsonBytes []byte) (DynamicSniffForwardRequest, error) { + var payload DynamicSniffForwardRequest + err := json.Unmarshal(jsonBytes, &payload) + if err != nil { + return DynamicSniffForwardRequest{}, err + } + return payload, nil +} + +// GetRequest returns the original http.Request object, for debugging purposes +func (dsfr *DynamicSniffForwardRequest) GetRequest() *http.Request { + return dsfr.rawRequest +} + +// GetRequestUUID returns the request UUID +// if this UUID is empty string, that might indicate the request +// is not coming from the dynamic router +func (dsfr *DynamicSniffForwardRequest) GetRequestUUID() string { + return dsfr.requestUUID +} diff --git a/example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go index d9b3fde..b64318f 100644 --- a/example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go +++ b/example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go @@ -6,15 +6,18 @@ import ( "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 + 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 @@ -55,11 +58,6 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl //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, "/") { - // Redirect to the index.html - http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) - return - } if strings.HasSuffix(r.URL.Path, ".html") { //Read the target file from embed.FS targetFilePath := strings.TrimPrefix(r.URL.Path, "/") @@ -72,8 +70,24 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl } body := string(targetFileContent) body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) - http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + 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 @@ -86,11 +100,18 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl 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, "/")) @@ -104,3 +125,32 @@ func (p *PluginUiRouter) Handler() http.Handler { p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) }) } + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the embed UI handler to the target http.ServeMux +func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/debugger/mod/zoraxy_plugin/static_router.go b/example/plugins/debugger/mod/zoraxy_plugin/static_router.go new file mode 100644 index 0000000..f4abcb7 --- /dev/null +++ b/example/plugins/debugger/mod/zoraxy_plugin/static_router.go @@ -0,0 +1,105 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "sort" + "strings" +) + +type PathRouter struct { + enableDebugPrint bool + pathHandlers map[string]http.Handler + defaultHandler http.Handler +} + +// NewPathRouter creates a new PathRouter +func NewPathRouter() *PathRouter { + return &PathRouter{ + enableDebugPrint: false, + pathHandlers: make(map[string]http.Handler), + } +} + +// RegisterPathHandler registers a handler for a path +func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) { + path = strings.TrimSuffix(path, "/") + p.pathHandlers[path] = handler +} + +// RemovePathHandler removes a handler for a path +func (p *PathRouter) RemovePathHandler(path string) { + delete(p.pathHandlers, path) +} + +// SetDefaultHandler sets the default handler for the router +// This handler will be called if no path handler is found +func (p *PathRouter) SetDefaultHandler(handler http.Handler) { + p.defaultHandler = handler +} + +// SetDebugPrintMode sets the debug print mode +func (p *PathRouter) SetDebugPrintMode(enable bool) { + p.enableDebugPrint = enable +} + +// StartStaticCapture starts the static capture ingress +func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.staticCaptureServeHTTP(w, r) + })) +} + +// staticCaptureServeHTTP serves the static capture path using user defined handler +func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) { + capturePath := r.Header.Get("X-Zoraxy-Capture") + if capturePath != "" { + if p.enableDebugPrint { + fmt.Printf("Using capture path: %s\n", capturePath) + } + originalURI := r.Header.Get("X-Zoraxy-Uri") + r.URL.Path = originalURI + if handler, ok := p.pathHandlers[capturePath]; ok { + handler.ServeHTTP(w, r) + return + } + } + p.defaultHandler.ServeHTTP(w, r) +} + +func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) { + if p.enableDebugPrint { + fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path) + keys := make([]string, 0, len(r.Header)) + for key := range r.Header { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range r.Header[key] { + fmt.Printf("%s: %s\n", key, value) + } + } + + fmt.Printf("\n\n**Request Details**\n\n") + fmt.Printf("Method: %s\n", r.Method) + fmt.Printf("URL: %s\n", r.URL.String()) + fmt.Printf("Proto: %s\n", r.Proto) + fmt.Printf("Host: %s\n", r.Host) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + fmt.Printf("ContentLength: %d\n", r.ContentLength) + fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding) + fmt.Printf("Close: %v\n", r.Close) + fmt.Printf("Form: %v\n", r.Form) + fmt.Printf("PostForm: %v\n", r.PostForm) + fmt.Printf("MultipartForm: %v\n", r.MultipartForm) + fmt.Printf("Trailer: %v\n", r.Trailer) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + + } +} diff --git a/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go index f3865ea..737e928 100644 --- a/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go +++ b/example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go @@ -4,9 +4,7 @@ import ( "encoding/json" "fmt" "os" - "os/signal" "strings" - "syscall" ) /* @@ -24,9 +22,9 @@ const ( PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore ) -type CaptureRule struct { - CapturePath string `json:"capture_path"` - IncludeSubPaths bool `json:"include_sub_paths"` +type StaticCaptureRule struct { + CapturePath string `json:"capture_path"` + //To be expanded } type ControlStatusCode int @@ -44,8 +42,9 @@ type SubscriptionEvent struct { } type RuntimeConstantValue struct { - ZoraxyVersion string `json:"zoraxy_version"` - ZoraxyUUID string `json:"zoraxy_uuid"` + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` + DevelopmentBuild bool `json:"development_build"` //Whether the Zoraxy is a development build or not } /* @@ -74,23 +73,24 @@ type IntroSpect struct { */ /* - Global Capture Settings - - Once plugin is enabled these rules always applies, no matter which HTTP Proxy rule it is enabled on - This captures the whole traffic of Zoraxy + 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 */ - GlobalCapturePaths []CaptureRule `json:"global_capture_path"` //Global traffic capture path of your plugin - GlobalCaptureIngress string `json:"global_capture_ingress"` //Global traffic capture ingress path of your plugin (e.g. /g_handler) + 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) /* - Always Capture Settings + Dynamic Capture Settings - Once the plugin is enabled on a given HTTP Proxy rule, - these always applies + 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 */ - AlwaysCapturePaths []CaptureRule `json:"always_capture_path"` //Always capture path of your plugin when enabled on a HTTP Proxy rule (e.g. /myapp) - AlwaysCaptureIngress string `json:"always_capture_ingress"` //Always capture ingress path of your plugin when enabled on a HTTP Proxy rule (e.g. /a_handler) + 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 @@ -174,25 +174,3 @@ func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { ServeIntroSpect(pluginSpect) return RecvConfigureSpec() } - -/* - -Shutdown handler - -This function will register a shutdown handler for the plugin -The shutdown callback will be called when the plugin is shutting down -You can use this to clean up resources like closing database connections -*/ - -func RegisterShutdownHandler(shutdownCallback func()) { - // Set up a channel to receive OS signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Start a goroutine to listen for signals - go func() { - <-sigChan - shutdownCallback() - os.Exit(0) - }() -} diff --git a/example/plugins/helloworld/main.go b/example/plugins/helloworld/main.go index 74188cf..4e94fea 100644 --- a/example/plugins/helloworld/main.go +++ b/example/plugins/helloworld/main.go @@ -7,7 +7,7 @@ import ( "net/http" "strconv" - plugin "example.com/zoraxy/helloworld/zoraxy_plugin" + plugin "example.com/zoraxy/helloworld/mod/zoraxy_plugin" ) const ( diff --git a/example/plugins/helloworld/zoraxy_plugin/README.txt b/example/plugins/helloworld/mod/zoraxy_plugin/README.txt similarity index 100% rename from example/plugins/helloworld/zoraxy_plugin/README.txt rename to example/plugins/helloworld/mod/zoraxy_plugin/README.txt diff --git a/example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go b/example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go new file mode 100644 index 0000000..9bed106 --- /dev/null +++ b/example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go @@ -0,0 +1,145 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" +) + +type PluginUiDebugRouter struct { + PluginID string //The ID of the plugin + TargetDir string //The directory where the UI files are stored + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + EnableDebug bool //Enable debug mode + terminateHandler func() //The handler to be called when the plugin is terminated +} + +// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system +// The targetDir is the directory where the UI files are stored (e.g. ./www) +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiDebugRouter{ + PluginID: pluginID, + TargetDir: targetDir, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from file system + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + //Check if the request is for a directory + //Check if the directory has an index.html file + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html" + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + if _, err := os.Stat(targetFilePath); err == nil { + //Serve the index.html file + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiDebugRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + if p.EnableDebug { + fmt.Print("Request URL:", r.URL.Path, " rewriting to ") + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL.Path = rewrittenURL + r.RequestURI = rewrittenURL + if p.EnableDebug { + fmt.Println(r.URL.Path) + } + + //Serve the file from the file system + fsHandler := http.FileServer(http.Dir(p.TargetDir)) + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the file system UI handler to the target http.ServeMux +func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/helloworld/mod/zoraxy_plugin/dynamic_router.go b/example/plugins/helloworld/mod/zoraxy_plugin/dynamic_router.go new file mode 100644 index 0000000..1dc53ce --- /dev/null +++ b/example/plugins/helloworld/mod/zoraxy_plugin/dynamic_router.go @@ -0,0 +1,162 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +/* + + Dynamic Path Handler + +*/ + +type SniffResult int + +const ( + SniffResultAccpet 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 == SniffResultAccpet { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else { + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("SKIP")) + } + })) +} + +// RegisterDynamicCaptureHandle register the dynamic capture ingress path with a handler +func (p *PathRouter) RegisterDynamicCaptureHandle(capture_ingress string, mux *http.ServeMux, handlefunc func(http.ResponseWriter, *http.Request)) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p.enableDebugPrint { + fmt.Println("Request captured by dynamic capture path: " + r.RequestURI) + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, capture_ingress) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + if rewrittenURL == "" { + rewrittenURL = "/" + } + if !strings.HasPrefix(rewrittenURL, "/") { + rewrittenURL = "/" + rewrittenURL + } + r.RequestURI = rewrittenURL + + handlefunc(w, r) + })) +} + +/* + Sniffing and forwarding + + The following functions are here to help with + sniffing and forwarding requests to the dynamic + router. +*/ +// A custom request object to be used in the dynamic sniffing +type DynamicSniffForwardRequest struct { + Method string `json:"method"` + Hostname string `json:"hostname"` + URL string `json:"url"` + Header map[string][]string `json:"header"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + RequestURI string `json:"request_uri"` + Proto string `json:"proto"` + ProtoMajor int `json:"proto_major"` + ProtoMinor int `json:"proto_minor"` + + /* Internal use */ + rawRequest *http.Request `json:"-"` + requestUUID string `json:"-"` +} + +// GetForwardRequestPayload returns a DynamicSniffForwardRequest object from an http.Request object +func EncodeForwardRequestPayload(r *http.Request) DynamicSniffForwardRequest { + return DynamicSniffForwardRequest{ + Method: r.Method, + Hostname: r.Host, + URL: r.URL.String(), + Header: r.Header, + RemoteAddr: r.RemoteAddr, + Host: r.Host, + RequestURI: r.RequestURI, + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + rawRequest: r, + } +} + +// DecodeForwardRequestPayload decodes JSON bytes into a DynamicSniffForwardRequest object +func DecodeForwardRequestPayload(jsonBytes []byte) (DynamicSniffForwardRequest, error) { + var payload DynamicSniffForwardRequest + err := json.Unmarshal(jsonBytes, &payload) + if err != nil { + return DynamicSniffForwardRequest{}, err + } + return payload, nil +} + +// GetRequest returns the original http.Request object, for debugging purposes +func (dsfr *DynamicSniffForwardRequest) GetRequest() *http.Request { + return dsfr.rawRequest +} + +// GetRequestUUID returns the request UUID +// if this UUID is empty string, that might indicate the request +// is not coming from the dynamic router +func (dsfr *DynamicSniffForwardRequest) GetRequestUUID() string { + return dsfr.requestUUID +} diff --git a/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go b/example/plugins/helloworld/mod/zoraxy_plugin/embed_webserver.go similarity index 71% rename from example/plugins/helloworld/zoraxy_plugin/embed_webserver.go rename to example/plugins/helloworld/mod/zoraxy_plugin/embed_webserver.go index c529e99..b64318f 100644 --- a/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go +++ b/example/plugins/helloworld/mod/zoraxy_plugin/embed_webserver.go @@ -12,12 +12,12 @@ import ( ) 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 - - terminateHandler func() //The handler to be called when the plugin is terminated + 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 @@ -58,11 +58,6 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl //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, "/") { - // Redirect to the index.html - http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) - return - } if strings.HasSuffix(r.URL.Path, ".html") { //Read the target file from embed.FS targetFilePath := strings.TrimPrefix(r.URL.Path, "/") @@ -75,8 +70,24 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl } body := string(targetFileContent) body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) - http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + 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 @@ -89,11 +100,18 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl 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, "/")) @@ -126,3 +144,13 @@ func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.Ser }() }) } + +// Attach the embed UI handler to the target http.ServeMux +func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/helloworld/mod/zoraxy_plugin/static_router.go b/example/plugins/helloworld/mod/zoraxy_plugin/static_router.go new file mode 100644 index 0000000..f4abcb7 --- /dev/null +++ b/example/plugins/helloworld/mod/zoraxy_plugin/static_router.go @@ -0,0 +1,105 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "sort" + "strings" +) + +type PathRouter struct { + enableDebugPrint bool + pathHandlers map[string]http.Handler + defaultHandler http.Handler +} + +// NewPathRouter creates a new PathRouter +func NewPathRouter() *PathRouter { + return &PathRouter{ + enableDebugPrint: false, + pathHandlers: make(map[string]http.Handler), + } +} + +// RegisterPathHandler registers a handler for a path +func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) { + path = strings.TrimSuffix(path, "/") + p.pathHandlers[path] = handler +} + +// RemovePathHandler removes a handler for a path +func (p *PathRouter) RemovePathHandler(path string) { + delete(p.pathHandlers, path) +} + +// SetDefaultHandler sets the default handler for the router +// This handler will be called if no path handler is found +func (p *PathRouter) SetDefaultHandler(handler http.Handler) { + p.defaultHandler = handler +} + +// SetDebugPrintMode sets the debug print mode +func (p *PathRouter) SetDebugPrintMode(enable bool) { + p.enableDebugPrint = enable +} + +// StartStaticCapture starts the static capture ingress +func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.staticCaptureServeHTTP(w, r) + })) +} + +// staticCaptureServeHTTP serves the static capture path using user defined handler +func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) { + capturePath := r.Header.Get("X-Zoraxy-Capture") + if capturePath != "" { + if p.enableDebugPrint { + fmt.Printf("Using capture path: %s\n", capturePath) + } + originalURI := r.Header.Get("X-Zoraxy-Uri") + r.URL.Path = originalURI + if handler, ok := p.pathHandlers[capturePath]; ok { + handler.ServeHTTP(w, r) + return + } + } + p.defaultHandler.ServeHTTP(w, r) +} + +func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) { + if p.enableDebugPrint { + fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path) + keys := make([]string, 0, len(r.Header)) + for key := range r.Header { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range r.Header[key] { + fmt.Printf("%s: %s\n", key, value) + } + } + + fmt.Printf("\n\n**Request Details**\n\n") + fmt.Printf("Method: %s\n", r.Method) + fmt.Printf("URL: %s\n", r.URL.String()) + fmt.Printf("Proto: %s\n", r.Proto) + fmt.Printf("Host: %s\n", r.Host) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + fmt.Printf("ContentLength: %d\n", r.ContentLength) + fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding) + fmt.Printf("Close: %v\n", r.Close) + fmt.Printf("Form: %v\n", r.Form) + fmt.Printf("PostForm: %v\n", r.PostForm) + fmt.Printf("MultipartForm: %v\n", r.MultipartForm) + fmt.Printf("Trailer: %v\n", r.Trailer) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + + } +} diff --git a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/helloworld/mod/zoraxy_plugin/zoraxy_plugin.go similarity index 79% rename from example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go rename to example/plugins/helloworld/mod/zoraxy_plugin/zoraxy_plugin.go index b316e6d..737e928 100644 --- a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go +++ b/example/plugins/helloworld/mod/zoraxy_plugin/zoraxy_plugin.go @@ -22,9 +22,9 @@ const ( PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore ) -type CaptureRule struct { - CapturePath string `json:"capture_path"` - IncludeSubPaths bool `json:"include_sub_paths"` +type StaticCaptureRule struct { + CapturePath string `json:"capture_path"` + //To be expanded } type ControlStatusCode int @@ -42,8 +42,9 @@ type SubscriptionEvent struct { } type RuntimeConstantValue struct { - ZoraxyVersion string `json:"zoraxy_version"` - ZoraxyUUID string `json:"zoraxy_uuid"` + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` + DevelopmentBuild bool `json:"development_build"` //Whether the Zoraxy is a development build or not } /* @@ -72,23 +73,24 @@ type IntroSpect struct { */ /* - Global Capture Settings - - Once plugin is enabled these rules always applies, no matter which HTTP Proxy rule it is enabled on - This captures the whole traffic of Zoraxy + 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 */ - GlobalCapturePaths []CaptureRule `json:"global_capture_path"` //Global traffic capture path of your plugin - GlobalCaptureIngress string `json:"global_capture_ingress"` //Global traffic capture ingress path of your plugin (e.g. /g_handler) + 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) /* - Always Capture Settings + Dynamic Capture Settings - Once the plugin is enabled on a given HTTP Proxy rule, - these always applies + 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 */ - AlwaysCapturePaths []CaptureRule `json:"always_capture_path"` //Always capture path of your plugin when enabled on a HTTP Proxy rule (e.g. /myapp) - AlwaysCaptureIngress string `json:"always_capture_ingress"` //Always capture ingress path of your plugin when enabled on a HTTP Proxy rule (e.g. /a_handler) + 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 diff --git a/example/plugins/upnp/api.go b/example/plugins/upnp/api.go new file mode 100644 index 0000000..5695e66 --- /dev/null +++ b/example/plugins/upnp/api.go @@ -0,0 +1,327 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" +) + +/* + API Handlers +*/ + +func handleUsableState(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + js, _ := json.Marshal(upnpRouterExists) + SendJSONResponse(w, string(js)) + } else if r.Method == "POST" { + //Try to probe the UPnP router again + TryStartUPnPClient() + if upnpRouterExists { + SendOK(w) + } else { + SendErrorResponse(w, "UPnP router not found") + } + } +} + +// Get or set the enable state of the plugin +func handleEnableState(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + js, _ := json.Marshal(upnpRuntimeConfig.Enabled) + SendJSONResponse(w, string(js)) + } else if r.Method == "POST" { + enable, err := PostBool(r, "enable") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + if !enable { + //Close all the port forwards if UPnP client is available + if upnpClient != nil { + for _, record := range upnpRuntimeConfig.ForwardRules { + err = upnpClient.ClosePort(record.PortNumber) + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + } + } + } else { + if upnpClient == nil { + SendErrorResponse(w, "No UPnP router in network") + return + } + + //Forward all the ports if UPnP client is available + if upnpClient != nil { + for _, record := range upnpRuntimeConfig.ForwardRules { + err = upnpClient.ForwardPort(record.PortNumber, record.RuleName) + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + } + } + } + + upnpRuntimeConfig.Enabled = enable + SaveRuntimeConfig() + } +} + +func handleForwardPortEdit(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + port, err := PostInt(r, "port") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + oldPort, err := PostInt(r, "oldPort") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + name, err := PostPara(r, "name") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + if port < 1 || port > 65535 { + SendErrorResponse(w, "invalid port number") + return + } + + //Check if the old port exists + found := false + for _, record := range upnpRuntimeConfig.ForwardRules { + if record.PortNumber == oldPort { + found = true + break + } + } + + if !found { + SendErrorResponse(w, "editing forward rule not found") + return + } + + //Delete the old port forward + if oldPort != port && upnpClient != nil { + //Remove the port forward if UPnP client is available + err = upnpClient.ClosePort(oldPort) + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + } + + //Remove from runtime config + for i, record := range upnpRuntimeConfig.ForwardRules { + if record.PortNumber == oldPort { + upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules[:i], upnpRuntimeConfig.ForwardRules[i+1:]...) + break + } + } + + //Create the new forward rule + if upnpClient != nil { + //Forward the port if UPnP client is available + err = upnpClient.ForwardPort(port, name) + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + } + + //Add to runtime config + upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules, &PortForwardRecord{ + RuleName: name, + PortNumber: port, + }) + + //Save the runtime config + SaveRuntimeConfig() + SendOK(w) + } +} + +// Remove a port forward +func handleForwardPortRemove(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + port, err := PostInt(r, "port") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + if upnpClient != nil { + //Remove the port forward if UPnP client is available + err = upnpClient.ClosePort(port) + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + } + + //Remove from runtime config + for i, record := range upnpRuntimeConfig.ForwardRules { + if record.PortNumber == port { + upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules[:i], upnpRuntimeConfig.ForwardRules[i+1:]...) + break + } + } + + //Save the runtime config + SaveRuntimeConfig() + SendOK(w) + } +} + +// Handle the port forward operations +func handleForwardPort(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + // List all the forwarded ports + js, _ := json.Marshal(upnpRuntimeConfig.ForwardRules) + SendJSONResponse(w, string(js)) + } else if r.Method == "POST" { + //Add a new port forward + port, err := PostInt(r, "port") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + name, err := PostPara(r, "name") + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + + if port < 1 || port > 65535 { + SendErrorResponse(w, "invalid port number") + return + } + + if upnpClient != nil { + //Forward the port if UPnP client is available + err = upnpClient.ForwardPort(port, name) + if err != nil { + SendErrorResponse(w, err.Error()) + return + } + } + + //Add to runtime config + upnpRuntimeConfig.ForwardRules = append(upnpRuntimeConfig.ForwardRules, &PortForwardRecord{ + RuleName: name, + PortNumber: port, + }) + + //Save the runtime config + SaveRuntimeConfig() + SendOK(w) + } +} + +/* + Network Utilities +*/ + +// Send JSON response, with an extra json header +func SendJSONResponse(w http.ResponseWriter, json string) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(json)) +} + +func SendErrorResponse(w http.ResponseWriter, errMsg string) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"error\":\"" + errMsg + "\"}")) +} + +func SendOK(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("\"OK\"")) +} + +// Get GET parameter +func GetPara(r *http.Request, key string) (string, error) { + // Get first value from the URL query + value := r.URL.Query().Get(key) + if len(value) == 0 { + return "", errors.New("invalid " + key + " given") + } + return value, nil +} + +// Get GET paramter as boolean, accept 1 or true +func GetBool(r *http.Request, key string) (bool, error) { + x, err := GetPara(r, key) + if err != nil { + return false, err + } + + // Convert to lowercase and trim spaces just once to compare + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "on": + return true, nil + case "0", "false", "off": + return false, nil + } + + return false, errors.New("invalid boolean given") +} + +// Get POST parameter +func PostPara(r *http.Request, key string) (string, error) { + // Try to parse the form + if err := r.ParseForm(); err != nil { + return "", err + } + // Get first value from the form + x := r.Form.Get(key) + if len(x) == 0 { + return "", errors.New("invalid " + key + " given") + } + return x, nil +} + +// Get POST paramter as boolean, accept 1 or true +func PostBool(r *http.Request, key string) (bool, error) { + x, err := PostPara(r, key) + if err != nil { + return false, err + } + + // Convert to lowercase and trim spaces just once to compare + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "on": + return true, nil + case "0", "false", "off": + return false, nil + } + + return false, errors.New("invalid boolean given") +} + +// Get POST paramter as int +func PostInt(r *http.Request, key string) (int, error) { + x, err := PostPara(r, key) + if err != nil { + return 0, err + } + + x = strings.TrimSpace(x) + rx, err := strconv.Atoi(x) + if err != nil { + return 0, err + } + + return rx, nil +} diff --git a/example/plugins/upnp/go.mod b/example/plugins/upnp/go.mod new file mode 100644 index 0000000..b0a83ca --- /dev/null +++ b/example/plugins/upnp/go.mod @@ -0,0 +1,13 @@ +module plugins.zoraxy.aroz.org/zoraxy/upnp + +go 1.23.6 + +require gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6 + +require ( + gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 // indirect + golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect + golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 // indirect + golang.org/x/text v0.3.6 // indirect +) diff --git a/example/plugins/upnp/go.sum b/example/plugins/upnp/go.sum new file mode 100644 index 0000000..16041ac --- /dev/null +++ b/example/plugins/upnp/go.sum @@ -0,0 +1,17 @@ +gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40 h1:dizWJqTWjwyD8KGcMOwgrkqu1JIkofYgKkmDeNE7oAs= +gitlab.com/NebulousLabs/fastrand v0.0.0-20181126182046-603482d69e40/go.mod h1:rOnSnoRyxMI3fe/7KIbVcsHRGxe30OONv8dEgo+vCfA= +gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6 h1:WKij6HF8ECp9E7K0E44dew9NrRDGiNR5u4EFsXnJUx4= +gitlab.com/NebulousLabs/go-upnp v0.0.0-20211002182029-11da932010b6/go.mod h1:vhrHTGDh4YR7wK8Z+kRJ+x8SF/6RUM3Vb64Si5FD0L8= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/example/plugins/upnp/icon.png b/example/plugins/upnp/icon.png new file mode 100644 index 0000000..66a78c4 Binary files /dev/null and b/example/plugins/upnp/icon.png differ diff --git a/example/plugins/upnp/icon.psd b/example/plugins/upnp/icon.psd new file mode 100644 index 0000000..5433131 Binary files /dev/null and b/example/plugins/upnp/icon.psd differ diff --git a/example/plugins/upnp/main.go b/example/plugins/upnp/main.go new file mode 100644 index 0000000..bbf25dd --- /dev/null +++ b/example/plugins/upnp/main.go @@ -0,0 +1,194 @@ +package main + +import ( + "embed" + _ "embed" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "time" + + "plugins.zoraxy.aroz.org/zoraxy/upnp/mod/upnpc" + plugin "plugins.zoraxy.aroz.org/zoraxy/upnp/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.plugins.upnp" + UI_PATH = "/ui" + WEB_ROOT = "/www" + CONFIG_FILE = "upnp.json" + AUTO_RENEW_INTERVAL = 12 * 60 * 60 // 12 hours +) + +type PortForwardRecord struct { + RuleName string + PortNumber int +} + +type UPnPConfig struct { + ForwardRules []*PortForwardRecord + Enabled bool +} + +//go:embed www/* +var content embed.FS + +// Runtime variables +var ( + upnpRouterExists bool = false + upnpRuntimeConfig *UPnPConfig = &UPnPConfig{ + ForwardRules: []*PortForwardRecord{}, + Enabled: false, + } + upnpClient *upnpc.UPnPClient = nil + renewTickerStop chan bool +) + +func main() { + //Handle introspect + runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: PLUGIN_ID, + Name: "UPnP Forwarder", + Author: "aroz.org", + AuthorContact: "https://github.com/aroz-online", + Description: "A UPnP Port Forwarder Plugin for Zoraxy", + URL: "https://github.com/aroz-online", + Type: plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + UIPath: UI_PATH, + }) + if err != nil { + //Terminate or enter standalone mode here + fmt.Println("This is a plugin for Zoraxy and should not be run standalone\n Visit zoraxy.aroz.org to download Zoraxy.") + panic(err) + } + + //Read the configuration from file + if _, err := os.Stat(CONFIG_FILE); os.IsNotExist(err) { + err = os.WriteFile(CONFIG_FILE, []byte("{}"), 0644) + if err != nil { + panic(err) + } + } + + cfgBytes, err := os.ReadFile(CONFIG_FILE) + if err != nil { + panic(err) + } + + //Load the configuration + err = json.Unmarshal(cfgBytes, &upnpRuntimeConfig) + if err != nil { + panic(err) + } + + //Start upnp client and auto-renew ticker + go func() { + TryStartUPnPClient() + }() + + //Serve the plugin UI + embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH) + // For debugging, use the following line instead + //embedWebRouter := plugin.NewPluginFileSystemUIRouter(PLUGIN_ID, "."+WEB_ROOT, UI_PATH) + //embedWebRouter.EnableDebug = true + embedWebRouter.RegisterTerminateHandler(func() { + if renewTickerStop != nil { + renewTickerStop <- true + } + // Do cleanup here if needed + upnpClient.Close() + }, nil) + embedWebRouter.AttachHandlerToMux(nil) + + //Serve the API + RegisterAPIs() + + //Start the IO server + fmt.Println("UPnP Forwarder 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) + } +} + +// RegisterAPIs registers the APIs for the plugin +func RegisterAPIs() { + http.HandleFunc(UI_PATH+"/api/usable", handleUsableState) + http.HandleFunc(UI_PATH+"/api/enable", handleEnableState) + http.HandleFunc(UI_PATH+"/api/forward", handleForwardPort) + http.HandleFunc(UI_PATH+"/api/edit", handleForwardPortEdit) + http.HandleFunc(UI_PATH+"/api/remove", handleForwardPortRemove) +} + +// TryStartUPnPClient tries to start the UPnP client +func TryStartUPnPClient() { + if renewTickerStop != nil { + renewTickerStop <- true + } + + // Create UPnP client + upnpClient, err := upnpc.NewUPNPClient() + if err != nil { + upnpRouterExists = false + upnpRuntimeConfig.Enabled = false + fmt.Println("UPnP router not found") + SaveRuntimeConfig() + return + } + upnpRouterExists = true + + //Check if the client is enabled by default + if upnpRuntimeConfig.Enabled { + // Forward all the ports + for _, rule := range upnpRuntimeConfig.ForwardRules { + err = upnpClient.ForwardPort(rule.PortNumber, rule.RuleName) + if err != nil { + fmt.Println("Unable to forward port", rule.PortNumber, ":", err) + return + } + } + } + + // Start the auto-renew ticker + _, renewTickerStop = SetupAutoRenewTicker() +} + +// SetupAutoRenewTicker sets up a ticker for auto-renewing the port forwarding rules +func SetupAutoRenewTicker() (*time.Ticker, chan bool) { + ticker := time.NewTicker(AUTO_RENEW_INTERVAL * time.Second) + closeChan := make(chan bool) + go func() { + for { + select { + case <-closeChan: + ticker.Stop() + return + case <-ticker.C: + if upnpClient != nil { + upnpClient.RenewForwardRules() + } + } + } + }() + return ticker, closeChan +} + +// SaveRuntimeConfig saves the runtime configuration to file +func SaveRuntimeConfig() error { + cfgBytes, err := json.Marshal(upnpRuntimeConfig) + if err != nil { + return err + } + + err = os.WriteFile(CONFIG_FILE, cfgBytes, 0644) + if err != nil { + return err + } + + return nil +} diff --git a/example/plugins/upnp/mod/upnpc/upnpc.go b/example/plugins/upnp/mod/upnpc/upnpc.go new file mode 100644 index 0000000..940f1f6 --- /dev/null +++ b/example/plugins/upnp/mod/upnpc/upnpc.go @@ -0,0 +1,135 @@ +package upnpc + +import ( + "errors" + "fmt" + "sync" + "time" + + "gitlab.com/NebulousLabs/go-upnp" +) + +/* + uPNP Module + + This module handles uPNP Connections to the gateway router and create a port forward entry + for the host system at the given port (set with -port paramter) +*/ + +type UPnPClient struct { + Connection *upnp.IGD //UPnP conenction object + ExternalIP string //Storage of external IP address + RequiredPorts []int //All the required ports will be recored + PolicyNames sync.Map //Name for the required port nubmer +} + +// NewUPNPClient creates a new UPnPClient object +func NewUPNPClient() (*UPnPClient, error) { + //Create uPNP forwarding in the NAT router + fmt.Println("Discovering UPnP router in Local Area Network...") + d, err := upnp.Discover() + if err != nil { + return &UPnPClient{}, err + } + + // discover external IP + ip, err := d.ExternalIP() + if err != nil { + return &UPnPClient{}, err + } + + //Create the final obejcts + newUPnPObject := &UPnPClient{ + Connection: d, + ExternalIP: ip, + RequiredPorts: []int{}, + } + + return newUPnPObject, nil +} + +// ForwardPort forwards a port to the host +func (u *UPnPClient) ForwardPort(portNumber int, ruleName string) error { + fmt.Println("UPnP forwarding new port: ", portNumber, "for "+ruleName+" service") + + //Check if port already forwarded + _, ok := u.PolicyNames.Load(portNumber) + if ok { + //Port already forward. Ignore this request + return errors.New("port already forwarded") + } + + // forward a port + err := u.Connection.Forward(uint16(portNumber), ruleName) + if err != nil { + return err + } + + u.RequiredPorts = append(u.RequiredPorts, portNumber) + u.PolicyNames.Store(portNumber, ruleName) + return nil +} + +// ClosePort closes the port forwarding +func (u *UPnPClient) ClosePort(portNumber int) error { + //Check if port is opened + portOpened := false + newRequiredPort := []int{} + for _, thisPort := range u.RequiredPorts { + if thisPort != portNumber { + newRequiredPort = append(newRequiredPort, thisPort) + } else { + portOpened = true + } + } + + if portOpened { + //Update the port list + u.RequiredPorts = newRequiredPort + + // Close the port + fmt.Println("Closing UPnP Port Forward: ", portNumber) + err := u.Connection.Clear(uint16(portNumber)) + + //Delete the name registry + u.PolicyNames.Delete(portNumber) + + if err != nil { + fmt.Println(err) + return err + } + } + return nil +} + +// Renew forward rules, prevent router lease time from flushing the Upnp config +func (u *UPnPClient) RenewForwardRules() { + if u.Connection == nil { + //UPnP router gone + return + } + portsToRenew := u.RequiredPorts + for _, thisPort := range portsToRenew { + ruleName, ok := u.PolicyNames.Load(thisPort) + if !ok { + continue + } + u.ClosePort(thisPort) + time.Sleep(100 * time.Millisecond) + u.ForwardPort(thisPort, ruleName.(string)) + } + fmt.Println("UPnP Port Forward rule renew completed") +} + +func (u *UPnPClient) Close() error { + //Shutdown the default UPnP Object + if u != nil { + for _, portNumber := range u.RequiredPorts { + err := u.Connection.Clear(uint16(portNumber)) + if err != nil { + return err + } + } + } + return nil +} diff --git a/example/plugins/upnp/mod/zoraxy_plugin/README.txt b/example/plugins/upnp/mod/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/example/plugins/upnp/mod/zoraxy_plugin/README.txt @@ -0,0 +1,19 @@ +# Zoraxy Plugin + +## Overview +This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components. + +## Instructions + +1. **Copy the Module:** + - Copy the entire `zoraxy_plugin` module to your plugin mod folder. + +2. **Include the Structure:** + - Ensure that you maintain the directory structure and file organization as provided in this module. + +3. **Modify as Needed:** + - Customize the copied module to implement the desired functionality for your plugin. + +## Directory Structure + zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup + embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages \ No newline at end of file diff --git a/example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go b/example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go new file mode 100644 index 0000000..9bed106 --- /dev/null +++ b/example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go @@ -0,0 +1,145 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" +) + +type PluginUiDebugRouter struct { + PluginID string //The ID of the plugin + TargetDir string //The directory where the UI files are stored + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + EnableDebug bool //Enable debug mode + terminateHandler func() //The handler to be called when the plugin is terminated +} + +// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system +// The targetDir is the directory where the UI files are stored (e.g. ./www) +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiDebugRouter{ + PluginID: pluginID, + TargetDir: targetDir, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from file system + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + //Check if the request is for a directory + //Check if the directory has an index.html file + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html" + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + if _, err := os.Stat(targetFilePath); err == nil { + //Serve the index.html file + targetFileContent, err := os.ReadFile(targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiDebugRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + if p.EnableDebug { + fmt.Print("Request URL:", r.URL.Path, " rewriting to ") + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL.Path = rewrittenURL + r.RequestURI = rewrittenURL + if p.EnableDebug { + fmt.Println(r.URL.Path) + } + + //Serve the file from the file system + fsHandler := http.FileServer(http.Dir(p.TargetDir)) + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the file system UI handler to the target http.ServeMux +func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go b/example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go new file mode 100644 index 0000000..1dc53ce --- /dev/null +++ b/example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go @@ -0,0 +1,162 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +/* + + Dynamic Path Handler + +*/ + +type SniffResult int + +const ( + SniffResultAccpet 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 == SniffResultAccpet { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else { + w.WriteHeader(http.StatusNotImplemented) + w.Write([]byte("SKIP")) + } + })) +} + +// RegisterDynamicCaptureHandle register the dynamic capture ingress path with a handler +func (p *PathRouter) RegisterDynamicCaptureHandle(capture_ingress string, mux *http.ServeMux, handlefunc func(http.ResponseWriter, *http.Request)) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if p.enableDebugPrint { + fmt.Println("Request captured by dynamic capture path: " + r.RequestURI) + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, capture_ingress) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + if rewrittenURL == "" { + rewrittenURL = "/" + } + if !strings.HasPrefix(rewrittenURL, "/") { + rewrittenURL = "/" + rewrittenURL + } + r.RequestURI = rewrittenURL + + handlefunc(w, r) + })) +} + +/* + Sniffing and forwarding + + The following functions are here to help with + sniffing and forwarding requests to the dynamic + router. +*/ +// A custom request object to be used in the dynamic sniffing +type DynamicSniffForwardRequest struct { + Method string `json:"method"` + Hostname string `json:"hostname"` + URL string `json:"url"` + Header map[string][]string `json:"header"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + RequestURI string `json:"request_uri"` + Proto string `json:"proto"` + ProtoMajor int `json:"proto_major"` + ProtoMinor int `json:"proto_minor"` + + /* Internal use */ + rawRequest *http.Request `json:"-"` + requestUUID string `json:"-"` +} + +// GetForwardRequestPayload returns a DynamicSniffForwardRequest object from an http.Request object +func EncodeForwardRequestPayload(r *http.Request) DynamicSniffForwardRequest { + return DynamicSniffForwardRequest{ + Method: r.Method, + Hostname: r.Host, + URL: r.URL.String(), + Header: r.Header, + RemoteAddr: r.RemoteAddr, + Host: r.Host, + RequestURI: r.RequestURI, + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + rawRequest: r, + } +} + +// DecodeForwardRequestPayload decodes JSON bytes into a DynamicSniffForwardRequest object +func DecodeForwardRequestPayload(jsonBytes []byte) (DynamicSniffForwardRequest, error) { + var payload DynamicSniffForwardRequest + err := json.Unmarshal(jsonBytes, &payload) + if err != nil { + return DynamicSniffForwardRequest{}, err + } + return payload, nil +} + +// GetRequest returns the original http.Request object, for debugging purposes +func (dsfr *DynamicSniffForwardRequest) GetRequest() *http.Request { + return dsfr.rawRequest +} + +// GetRequestUUID returns the request UUID +// if this UUID is empty string, that might indicate the request +// is not coming from the dynamic router +func (dsfr *DynamicSniffForwardRequest) GetRequestUUID() string { + return dsfr.requestUUID +} diff --git a/example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..b64318f --- /dev/null +++ b/example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,156 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + TargetFs *embed.FS //The embed.FS where the UI files are stored + TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui + EnableDebug bool //Enable debug mode + terminateHandler func() //The handler to be called when the plugin is terminated +} + +// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } else if strings.HasSuffix(r.URL.Path, "/") { + // Check if the directory has an index.html file + indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html" + indexFilePath = p.TargetFsPrefix + "/" + indexFilePath + indexFilePath = strings.TrimPrefix(indexFilePath, "/") + indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath) + if err == nil { + body := string(indexFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + return + } + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + if p.EnableDebug { + fmt.Print("Request URL:", r.URL.Path, " rewriting to ") + } + + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + r.RequestURI = rewrittenURL + if p.EnableDebug { + fmt.Println(r.URL.Path) + } + + //Serve the file from the embed.FS + subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) + if err != nil { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} + +// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter +// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager +// if mux is nil, the handler will be registered to http.DefaultServeMux +func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) { + p.terminateHandler = termFunc + if mux == nil { + mux = http.DefaultServeMux + } + mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) { + p.terminateHandler() + w.WriteHeader(http.StatusOK) + go func() { + //Make sure the response is sent before the plugin is terminated + time.Sleep(100 * time.Millisecond) + os.Exit(0) + }() + }) +} + +// Attach the embed UI handler to the target http.ServeMux +func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) { + if mux == nil { + mux = http.DefaultServeMux + } + + p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/") + mux.Handle(p.HandlerPrefix+"/", p.Handler()) +} diff --git a/example/plugins/upnp/mod/zoraxy_plugin/static_router.go b/example/plugins/upnp/mod/zoraxy_plugin/static_router.go new file mode 100644 index 0000000..f4abcb7 --- /dev/null +++ b/example/plugins/upnp/mod/zoraxy_plugin/static_router.go @@ -0,0 +1,105 @@ +package zoraxy_plugin + +import ( + "fmt" + "net/http" + "sort" + "strings" +) + +type PathRouter struct { + enableDebugPrint bool + pathHandlers map[string]http.Handler + defaultHandler http.Handler +} + +// NewPathRouter creates a new PathRouter +func NewPathRouter() *PathRouter { + return &PathRouter{ + enableDebugPrint: false, + pathHandlers: make(map[string]http.Handler), + } +} + +// RegisterPathHandler registers a handler for a path +func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) { + path = strings.TrimSuffix(path, "/") + p.pathHandlers[path] = handler +} + +// RemovePathHandler removes a handler for a path +func (p *PathRouter) RemovePathHandler(path string) { + delete(p.pathHandlers, path) +} + +// SetDefaultHandler sets the default handler for the router +// This handler will be called if no path handler is found +func (p *PathRouter) SetDefaultHandler(handler http.Handler) { + p.defaultHandler = handler +} + +// SetDebugPrintMode sets the debug print mode +func (p *PathRouter) SetDebugPrintMode(enable bool) { + p.enableDebugPrint = enable +} + +// StartStaticCapture starts the static capture ingress +func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) { + if !strings.HasSuffix(capture_ingress, "/") { + capture_ingress = capture_ingress + "/" + } + mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p.staticCaptureServeHTTP(w, r) + })) +} + +// staticCaptureServeHTTP serves the static capture path using user defined handler +func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) { + capturePath := r.Header.Get("X-Zoraxy-Capture") + if capturePath != "" { + if p.enableDebugPrint { + fmt.Printf("Using capture path: %s\n", capturePath) + } + originalURI := r.Header.Get("X-Zoraxy-Uri") + r.URL.Path = originalURI + if handler, ok := p.pathHandlers[capturePath]; ok { + handler.ServeHTTP(w, r) + return + } + } + p.defaultHandler.ServeHTTP(w, r) +} + +func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) { + if p.enableDebugPrint { + fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path) + keys := make([]string, 0, len(r.Header)) + for key := range r.Header { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, value := range r.Header[key] { + fmt.Printf("%s: %s\n", key, value) + } + } + + fmt.Printf("\n\n**Request Details**\n\n") + fmt.Printf("Method: %s\n", r.Method) + fmt.Printf("URL: %s\n", r.URL.String()) + fmt.Printf("Proto: %s\n", r.Proto) + fmt.Printf("Host: %s\n", r.Host) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + fmt.Printf("ContentLength: %d\n", r.ContentLength) + fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding) + fmt.Printf("Close: %v\n", r.Close) + fmt.Printf("Form: %v\n", r.Form) + fmt.Printf("PostForm: %v\n", r.PostForm) + fmt.Printf("MultipartForm: %v\n", r.MultipartForm) + fmt.Printf("Trailer: %v\n", r.Trailer) + fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr) + fmt.Printf("RequestURI: %s\n", r.RequestURI) + + } +} diff --git a/example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..737e928 --- /dev/null +++ b/example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,176 @@ +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 +} + +/* +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, see Zoraxy documentation for more details +} + +/* +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 + //To be expanded +} + +/* +RecvExecuteConfigureSpec Function + +This function will read the configure spec from Zoraxy +and return the ConfigureSpec object + +Place this function after ServeIntroSpect function in your plugin main function +*/ +func RecvConfigureSpec() (*ConfigureSpec, error) { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-configure=") { + var configSpec ConfigureSpec + if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil { + return nil, err + } + return &configSpec, nil + } else if arg == "-configure" { + var configSpec ConfigureSpec + var nextArg string + if len(os.Args) > i+1 { + nextArg = os.Args[i+1] + if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("No port specified after -configure flag") + } + return &configSpec, nil + } + } + return nil, fmt.Errorf("No -configure flag found") +} + +/* +ServeAndRecvSpec Function + +This function will serve the intro spect and return the configure spec +See the ServeIntroSpect and RecvConfigureSpec for more details +*/ +func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { + ServeIntroSpect(pluginSpect) + return RecvConfigureSpec() +} diff --git a/example/plugins/upnp/upnp.json b/example/plugins/upnp/upnp.json new file mode 100644 index 0000000..2f45679 --- /dev/null +++ b/example/plugins/upnp/upnp.json @@ -0,0 +1 @@ +{"ForwardRules":[],"Enabled":false} \ No newline at end of file diff --git a/example/plugins/upnp/www/index.html b/example/plugins/upnp/www/index.html new file mode 100644 index 0000000..2b33904 --- /dev/null +++ b/example/plugins/upnp/www/index.html @@ -0,0 +1,302 @@ + + + + + + +
UPnP Port Forwarder
+Port forward using UPnP protocol, only works with some of the supported gateway routers
+No UPnP router found in network. Please ensure your router supports UPnP and is enabled.
+ +UPnP Port Forwarder
+| Rule Name | +Forwarded Port | +Action | +
|---|---|---|
| Example Rule | +8080 | ++ + + | +
Port Forward Rules
+ + +Country Whitelist
This will allow all requests from the selected country. The requester's location is estimated from their IP address and may not be 100% accurate.
@@ -1043,6 +1058,31 @@ enableWhitelist(); }) }); + + $.get("/api/whitelist/allowLocal", function(data){ + if (data == true){ + $('#enableWhitelistLoopback').parent().checkbox("set checked"); + }else{ + $('#enableWhitelistLoopback').parent().checkbox("set unchecked"); + } + + //Register on change event + $("#enableWhitelistLoopback").off("change").on("change", function(){ + enableWhitelistLoopback(); + }) + }); + } + + function enableWhitelistLoopback(){ + var isChecked = $('#enableWhitelistLoopback').is(':checked'); + $.cjax({ + type: 'POST', + url: '/api/whitelist/allowLocal', + data: { enable: isChecked, id: currentEditingAccessRule}, + success: function(data){ + msgbox("Loopback whitelist " + (isChecked ? "enabled" : "disabled"), true); + } + }); } /* @@ -1606,4 +1646,7 @@ function handleUnban(targetIp){ removeIpBlacklist(targetIp); } + + //Bind UI events + $(".advanceSettings").accordion(); \ No newline at end of file diff --git a/src/web/components/gan.html b/src/web/components/gan.html deleted file mode 100644 index 4c89b49..0000000 --- a/src/web/components/gan.html +++ /dev/null @@ -1,231 +0,0 @@ -Global Area Network
-Virtual Network Hub that allows all networked devices to communicate as if they all reside in the same physical data center or cloud region
-Global Area Network will be deprecating in v3.2.x and moved to Plugin
-
-
- Network Controller ID
-
- | Network ID | -Name | -Description | -Subnet (Assign Range) | -Nodes | -Actions | -
|---|---|---|---|---|---|
| No Global Area Network Found on this host | -|||||
- - -
- -- -
You can change the network name and description below.
The name and description is only for easy management purpose and will not effect the network operation.
-
Settings
-| IPv4 Auto-Assign | -
|---|
-
Custom IP Range
-Manual IP Range Configuration. The IP range must be within the selected CIDR range.
-
Use Utilities > IP to CIDR tool if you are not too familiar with CIDR notations.
Members
-To join this network using command line, type sudo zerotier-cli join on your device terminal
| Auth | -Address | -Name | -Managed IP | -Authorized Since | -Version | -Remove | -
|---|---|---|---|---|---|---|
Add Controller as Member
-Optionally you can add the network controller (ZeroTier running on the Zoraxy node) as member for cross GAN reverse proxy to bypass NAT limitations.
- - --
Plugins
@@ -7,6 +109,70 @@This feature is experimental and may not work as expected. Use with caution.
+ Plugin Map
+ Assigning a plugin to a tag will make the plugin available to the HTTP Proxy rule with the same tag.
+
+ +
+ +
+ +
+ +
+ Plugin List
+ A list of installed plugins and their enable state
+
| Country ISO Code | -Unique Visitors | +Country ISO Code | +Unique Visitors |
|---|
| Proxy Type | -Count | +Proxy Type | +Count | Running Since | - - |
|---|---|---|---|
| ZeroTier Linked | -- | ||
| Enable SSH Loopback | @@ -224,7 +219,6 @@ $("#zoraxyinfo .version").text(data.Version); $(".zrversion").text("v." + data.Version); //index footer $("#zoraxyinfo .boottime").text(timeConverter(data.BootTime) + ` ( ${secondsToDhms(parseInt(Date.now()/1000) - data.BootTime)} ago)`); - $("#zoraxyinfo .zt").html(data.ZerotierConnected?` Connected`:` Link Error`); $("#zoraxyinfo .sshlb").html(data.EnableSshLoopback?` Enabled`:`Disabled`); }); diff --git a/src/web/darktheme.css b/src/web/darktheme.css index a2d52ae..ca8bc75 100644 --- a/src/web/darktheme.css +++ b/src/web/darktheme.css @@ -18,6 +18,7 @@ body:not(.darkTheme){ --item_color: #5e5d5d; --item_color_select: rgba(0,0,0,.87); --text_color: #414141; + --text_color_secondary: #4b4b4b; --input_color: white; --divider_color: #cacaca; --text_color_inverted: #fcfcfc; @@ -25,25 +26,26 @@ body:not(.darkTheme){ --button_border_color: #dedede; --buttom_toggle_active: #01dc64; --buttom_toggle_disabled: #f2f2f2; + --table_header_color: transparent; --table_bg_default: transparent; --status_dot_bg: #e8e8e8; --theme_background: linear-gradient(60deg, rgb(84, 58, 183) 0%, rgb(0, 172, 193) 100%); - --theme_background_inverted: linear-gradient(215deg, rgba(38,60,71,1) 13%, rgba(2,3,42,1) 84%); - --theme_green: linear-gradient(270deg, #27e7ff, #00ca52); + --theme_background_inverted: linear-gradient(45deg, rgba(18,19,23,1) 21%, rgba(50,59,66,1) 79%); + --theme_green: linear-gradient(45deg, rgba(65,199,175,1) 21%, rgba(84,227,142,1) 79%); --theme_red: linear-gradient(203deg, rgba(250,172,38,1) 17%, rgba(202,0,37,1) 78%); } body.darkTheme{ --theme_bg: #1e1e1e; - --theme_bg_primary: #151517; - --theme_bg_secondary:#1b3572; + --theme_bg_primary: #161617; + --theme_bg_secondary:#528eec; --theme_highlight: #6a7792; --theme_bg_active: #020101; --theme_bg_inverted: #f8f8f9; --theme_advance: #000000; --item_color: #cacaca; - --text_color: #dee1e4; + --text_color: #f5f5f7; --text_color_secondary: #b5c0c7; --input_color: black; --divider_color: #282828; @@ -53,12 +55,13 @@ body.darkTheme{ --button_border_color: #646464; --buttom_toggle_active: #01dc64; --buttom_toggle_disabled: #2b2b2b; + --table_header_color: rgba(85,131,238,1); --table_bg_default: #121214; --status_dot_bg: #232323; - --theme_background: linear-gradient(23deg, rgba(2,74,106,1) 17%, rgba(46,12,136,1) 86%); - --theme_background_inverted: linear-gradient(215deg, rgba(38,60,71,1) 13%, rgba(2,3,42,1) 84%); - --theme_green: linear-gradient(214deg, rgba(25,128,94,1) 17%, rgba(62,76,111,1) 78%); + --theme_background: linear-gradient(45deg, rgba(85,131,238,1) 21%, rgba(65,216,221,1) 79%); + --theme_background_inverted:linear-gradient(45deg, rgba(18,19,23,1) 21%, rgba(50,59,66,1) 79%); + --theme_green: linear-gradient(45deg, rgba(65,199,175,1) 21%, rgba(84,227,142,1) 79%); --theme_red: linear-gradient(203deg, rgba(250,172,38,1) 17%, rgba(202,0,37,1) 78%); } @@ -85,7 +88,7 @@ body.darkTheme .ui.header { body.darkTheme p, body.darkTheme span{ - color: var(--text_color_secondary); + color: var(--text_color); } body.darkTheme .ui.secondary.menu .dropdown.item:hover, @@ -152,6 +155,10 @@ body.darkTheme .ui.table tfoot td { color: #ffffff !important; } +body.darkTheme .ui.table thead th{ + background-color: var(--table_header_color); +} + body.darkTheme .ui.input input, body.darkTheme .ui.input input::placeholder, body.darkTheme .ui.input input:focus, @@ -220,6 +227,22 @@ body.darkTheme .ui.checkbox:not(.toggle) input[type="checkbox"]{ border: 1px solid var(--button_border_color) !important; } +/* message box */ +body.darkTheme #messageBox i{ + color: var(--text_color) !important; +} +body.darkTheme #messageBox.ui.green.message { + background-color: #1ebc30 !important; + color: white; +} + +body.darkTheme #messageBox.ui.red.message { + background-color: #db2828 !important; + color: white; +} + + + /* Generic dropdown overwrites */ body.darkTheme .ui.selection.dropdown { background-color: var(--theme_bg) !important; @@ -303,6 +326,12 @@ body.darkTheme .ui.segment:not(.basic):not(.tab) { border: 1px solid transparent !important; } +body.darkTheme .ui.segment.advanceoptions { + background-color: var(--theme_bg) !important; + color: var(--text_color) !important; + border: 1px solid var(--divider_color) !important; +} + body.darkTheme .ui.segment{ background-color: transparent !important; color: var(--text_color) !important; @@ -343,7 +372,8 @@ body.darkTheme .ui.form .field .ui.checkbox input:checked ~ label { } body.darkTheme .ui.basic.label { - background-color: var(--theme_bg_secondary) !important; + /* background-color: var(--theme_bg_secondary) !important; */ + background-color: var(--theme_highlight) !important; color: var(--text_color) !important; } @@ -354,7 +384,7 @@ body.darkTheme .ui.form .grouped.fields label { /* Confirm Box */ body.darkTheme .confirmBoxBody { - background-color: var(--theme_bg) !important; + background-color: var(--text_color_inverted) !important; color: var(--text_color) !important; border: 1px solid var(--divider_color) !important; } @@ -395,11 +425,11 @@ body.darkTheme .confirmBoxBody .questionToConfirm { } body.darkTheme #confirmBox .ui.top.attached.progress{ - background-color: var(--theme_bg_secondary) !important; + background-color: var(--theme_highlight) !important; } body.darkTheme #confirmBox .ui.top.attached.progress .bar { - background-color: var(--theme_highlight) !important; + background-color: var(--buttom_toggle_active) !important; } /* Tour Modal */ @@ -465,7 +495,7 @@ body.darkTheme .ui.celled.sortable.unstackable.compact.table tfoot td { } body.darkTheme .ui.celled.sortable.unstackable.compact.table thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; } body.darkTheme .ui.celled.sortable.unstackable.compact.table tbody tr:hover { @@ -688,7 +718,7 @@ body.darkTheme #proxyTable { } body.darkTheme #proxyTable thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -735,7 +765,7 @@ body.darkTheme #proxyTable tbody td .ui.circular.red.basic.mini.icon.button:hove */ body.darkTheme #redirectset .ui.sortable.unstackable.celled.table thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -787,6 +817,13 @@ body.darkTheme .ui.message { border: 1px solid var(--message_border_color) !important; } +body.darkTheme .ui.yellow.message { + background-color: #b58105 !important; +} + +body.darkTheme .ui.yellow.message .header { + color: var(--text_color) !important; +} /* Access Rules */ @@ -854,7 +891,7 @@ body.darkTheme #access .ui.unstackable.basic.celled.table{ } body.darkTheme #access .ui.unstackable.basic.celled.table thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -908,7 +945,7 @@ body.darkTheme #ipTable { } body.darkTheme #ipTable thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -993,7 +1030,7 @@ body.darkTheme #gan .clickable.iprange.active { } body.darkTheme #gan thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -1037,7 +1074,7 @@ body.darkTheme .ui.utmloading.segment .ui.inverted.dimmer .ui.text.loader { */ body.darkTheme .ui.celled.unstackable.table:not(.basic) th{ - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -1149,7 +1186,7 @@ body.darkTheme .ui.celled.compact.table { } body.darkTheme .ui.celled.compact.table thead th { - background-color: var(--theme_bg_secondary) !important; + background-color: var(--table_header_color) !important; color: var(--text_color) !important; border-color: var(--divider_color) !important; } @@ -1158,3 +1195,14 @@ body.darkTheme .ui.list .list > .item .header, .ui.list > .item .header, body.darkTheme .ui.list .list > .item .description, .ui.list > .item .description { color: var(--text_color) !important; } + + +/* Others (not categorized) */ +body.darkTheme .ui.horizontal.divider { + color: var(--text_color_secondary) !important; +} + +body.darkTheme .ui.checkbox input:checked ~ .box::after, +body.darkTheme .ui.checkbox input:checked ~ label::after { + color: var(--text_color_inverted) !important; +} \ No newline at end of file diff --git a/src/web/index.html b/src/web/index.html index 8c71281..98ea85b 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -67,9 +67,6 @@ Access Control - - Global Area Network - |

