Added UI for plugin system and upnp example

- Added wip UI for plugin tag system
- Added upnp port forwarder plugin
- Added error and fatal printout for plugins
- Optimized UI flow for plugin context window
- Added dev web server for plugin development purpose
This commit is contained in:
Toby Chui 2025-03-15 21:02:44 +08:00
parent 4a99afa2f0
commit f8270e46c2
34 changed files with 3143 additions and 14 deletions

View File

@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
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
@ -130,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())
}

View File

@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
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
@ -130,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())
}

327
example/plugins/upnp/api.go Normal file
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

View File

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

View File

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

View File

@ -0,0 +1,19 @@
# Zoraxy Plugin
## Overview
This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components.
## Instructions
1. **Copy the Module:**
- Copy the entire `zoraxy_plugin` module to your plugin mod folder.
2. **Include the Structure:**
- Ensure that you maintain the directory structure and file organization as provided in this module.
3. **Modify as Needed:**
- Customize the copied module to implement the desired functionality for your plugin.
## Directory Structure
zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup
embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages

View File

@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -0,0 +1,162 @@
package zoraxy_plugin
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
/*
Dynamic Path Handler
*/
type SniffResult int
const (
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
}

View File

@ -0,0 +1,156 @@
package zoraxy_plugin
import (
"embed"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type PluginUiRouter struct {
PluginID string //The ID of the plugin
TargetFs *embed.FS //The embed.FS where the UI files are stored
TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS
// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored
// The targetFsPrefix should be relative to the root of the embed.FS
// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(targetFsPrefix, "/") {
targetFsPrefix = "/" + targetFsPrefix
}
targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/")
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiRouter{
PluginID: pluginID,
TargetFs: targetFs,
TargetFsPrefix: targetFsPrefix,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from embed.FS
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetFsPrefix + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
// Check if the directory has an index.html file
indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
indexFilePath = strings.TrimPrefix(indexFilePath, "/")
indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
if err == nil {
body := string(indexFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL, _ = url.Parse(rewrittenURL)
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the embed.FS
subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/"))
if err != nil {
fmt.Println(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -0,0 +1,105 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"sort"
"strings"
)
type PathRouter struct {
enableDebugPrint bool
pathHandlers map[string]http.Handler
defaultHandler http.Handler
}
// NewPathRouter creates a new PathRouter
func NewPathRouter() *PathRouter {
return &PathRouter{
enableDebugPrint: false,
pathHandlers: make(map[string]http.Handler),
}
}
// RegisterPathHandler registers a handler for a path
func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) {
path = strings.TrimSuffix(path, "/")
p.pathHandlers[path] = handler
}
// RemovePathHandler removes a handler for a path
func (p *PathRouter) RemovePathHandler(path string) {
delete(p.pathHandlers, path)
}
// SetDefaultHandler sets the default handler for the router
// This handler will be called if no path handler is found
func (p *PathRouter) SetDefaultHandler(handler http.Handler) {
p.defaultHandler = handler
}
// SetDebugPrintMode sets the debug print mode
func (p *PathRouter) SetDebugPrintMode(enable bool) {
p.enableDebugPrint = enable
}
// StartStaticCapture starts the static capture ingress
func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) {
if !strings.HasSuffix(capture_ingress, "/") {
capture_ingress = capture_ingress + "/"
}
mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p.staticCaptureServeHTTP(w, r)
}))
}
// staticCaptureServeHTTP serves the static capture path using user defined handler
func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) {
capturePath := r.Header.Get("X-Zoraxy-Capture")
if capturePath != "" {
if p.enableDebugPrint {
fmt.Printf("Using capture path: %s\n", capturePath)
}
originalURI := r.Header.Get("X-Zoraxy-Uri")
r.URL.Path = originalURI
if handler, ok := p.pathHandlers[capturePath]; ok {
handler.ServeHTTP(w, r)
return
}
}
p.defaultHandler.ServeHTTP(w, r)
}
func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) {
if p.enableDebugPrint {
fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path)
keys := make([]string, 0, len(r.Header))
for key := range r.Header {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, value := range r.Header[key] {
fmt.Printf("%s: %s\n", key, value)
}
}
fmt.Printf("\n\n**Request Details**\n\n")
fmt.Printf("Method: %s\n", r.Method)
fmt.Printf("URL: %s\n", r.URL.String())
fmt.Printf("Proto: %s\n", r.Proto)
fmt.Printf("Host: %s\n", r.Host)
fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr)
fmt.Printf("RequestURI: %s\n", r.RequestURI)
fmt.Printf("ContentLength: %d\n", r.ContentLength)
fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding)
fmt.Printf("Close: %v\n", r.Close)
fmt.Printf("Form: %v\n", r.Form)
fmt.Printf("PostForm: %v\n", r.PostForm)
fmt.Printf("MultipartForm: %v\n", r.MultipartForm)
fmt.Printf("Trailer: %v\n", r.Trailer)
fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr)
fmt.Printf("RequestURI: %s\n", r.RequestURI)
}
}

View File

@ -0,0 +1,175 @@
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"`
}
/*
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()
}

View File

@ -0,0 +1 @@
{"ForwardRules":[{"RuleName":"EarlySpring","PortNumber":2016}],"Enabled":false}

View File

@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- CSRF token, if your plugin need to make POST request to backend -->
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
<title>UPnP Port Forwarder | Zoraxy</title>
<link rel="stylesheet" href="/script/semantic/semantic.min.css">
<script src="/script/jquery-3.6.0.min.js"></script>
<script src="/script/semantic/semantic.min.js"></script>
<script src="/script/utils.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/main.css">
<style>
body {
background:none;
}
</style>
</head>
<body>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<div id="upnpForwarder" class="standardContainer">
<div id="upnpRouterNotFoundWarning" class="ui basic segment" style="display: none;">
<h2>UPnP Port Forwarder</h2>
<p>Port forward using UPnP protocol, only works with some of the supported gateway routers</p>
</div>
<div class="ui basic segment">
<div class="ui message">
<div class="header"><i class="yellow exclamation triangle icon"></i> UPnP Gateway Not Found</div>
<p>No UPnP router found in network. Please ensure your router supports UPnP and is enabled.</p>
<button id="retryBtn" onclick="searchUpnpRouter();" class="ui basic small button"><i class="green refresh icon"></i> Search again</button>
</div>
<div class="ui basic segment">
<h3 class="ui header">UPnP Port Forwarder</h3>
<div class="ui toggle checkbox">
<input type="checkbox" id="upnpToggle" onchange="toggleUpnpState();">
<label>Enable UPnP Forwarding</label>
</div>
</div>
<div class="ui divider"></div>
<table class="ui celled table">
<thead>
<tr>
<th>Rule Name</th>
<th>Forwarded Port</th>
<th>Action</th>
</tr>
</thead>
<tbody id="forwardList">
<tr>
<td>Example Rule</td>
<td>8080</td>
<td>
<button class="ui button">Edit</button>
<button class="ui button">Remove</button>
</td>
</tr>
</tbody>
</table>
<div class="ui divider"></div>
<div>
<h4>Port Forward Rules</h4>
<form class="ui form" id="addRuleForm">
<div class="field">
<label>Rule Name</label>
<input type="text" name="ruleName" placeholder="Rule Name">
</div>
<div class="field">
<label>Port</label>
<input type="number" name="port" placeholder="Port" min="1" max="65535">
</div>
<button onclick="handleAddForward(event);" class="ui small basic button"><i class="ui blue add icon"></i> Add Rule</button>
</form>
</div>
</div>
</div>
<script>
function toggleUpnpState() {
let isChecked = $("#upnpToggle").prop("checked");
$.cjax({
url: './api/toggle',
method: "POST",
data: {
enable: isChecked
},
success: function(data) {
if (data.error != undefined) {
// Error
parent.msgbox(data.error, false);
} else {
// Success
parent.msgbox("UPnP Forwarding " + (isChecked ? "enabled" : "disabled"), true);
}
}
});
}
function initUPnPEnableState(){
$.cjax({
url: './api/enable',
success: function(data) {
if (data == true){
//Upnp forwarding enabled
$("#upnpToggle").prop("checked", true);
}else{
//Upnp forwarding disabled
$("#upnpToggle").prop("checked", false);
}
}
});
}
initUPnPEnableState();
function searchUpnpRouter(){
$("#retryBtn").addClass("loading");
parent.msgbox("Searching for UPnP router (will take a few minutes)...", true);
$.cjax({
url: './api/usable',
method: "POST",
success: function(data) {
if (data.error != undefined){
//Not found
parent.msgbox("UPnP router not found", false);
}else{
//Found
parent.msgbox("UPnP router discovered", true);
}
initUpnpUsableState();
$("#retryBtn").removeClass("loading");
}
});
}
//Check if UPnP is usable
function initUpnpUsableState(){
$.cjax({
url: './api/usable',
success: function(data) {
if (data == true){
//Upnp router found in network, enable the page
$('#upnpRouterNotFoundWarning').hide();
}else{
//No upnp router found in network, disable the page
$('#upnpForwarder').addClass('disabled');
$('#upnpRouterNotFoundWarning').show();
}
}
});
}
function handleAddForward(event){
event.preventDefault();
let ruleName = $("#addRuleForm input[name='ruleName']").val();
let port = $("#addRuleForm input[name='port']").val();
if (ruleName == "" || port == ""){
parent.msgbox("Please fill in all fields", false);
return;
}
$.cjax({
url: './api/forward',
method: "POST",
data: {
name: ruleName,
port: port
},
success: function(data) {
if (data.error != undefined){
//Error
parent.msgbox(data.error, false);
}else{
//Success
parent.msgbox("Rule added successfully", true);
initForwardList();
}
$("#addRuleForm input[name='ruleName']").val('');
$("#addRuleForm input[name='port']").val('');
}
});
}
function handleRemoveForward(portNo){
$.cjax({
url: './api/remove',
method: "POST",
data: {
port: portNo
},
success: function(data) {
if (data.error != undefined){
//Error
parent.msgbox(data.error, false);
}else{
//Success
parent.msgbox("Rule removed successfully", true);
initForwardList();
}
}
});
}
function editForwardRule(row){
let ruleName = $(row).closest('tr').find('td:eq(0)').text();
let portNumber = $(row).closest('tr').find('td:eq(1)').text();
$(row).closest('tr').html(`
<td>
<div class="ui fluid input">
<input type="text" value="${ruleName}" onkeypress="if(event.key === 'Enter') $(this).closest('tr').find('td:eq(1) input').focus();">
</div>
</td>
<td>
<div class="ui fluid input">
<input type="number" value="${portNumber}" class="ui input" min="1" max="65535" onkeypress="if(event.key === 'Enter') saveForwardRule(this, '${portNumber}');">
</div>
</td>
<td>
<button onclick="saveForwardRule(this, '${portNumber}');" class="ui basic small circular icon button"><i class="ui green save icon"></i></button>
<button onclick="cancelEditForwardRule(this, '${ruleName}', '${portNumber}');" class="ui basic small circular icon button"><i class="ui cancel icon"></i></button>
</td>
`);
}
function cancelEditForwardRule(){
initForwardList();
}
function saveForwardRule(row, portNo){
let ruleName = $(row).closest('tr').find('td:eq(0) input').val();
let newPortNo = $(row).closest('tr').find('td:eq(1) input').val();
if (ruleName == "" || newPortNo == ""){
parent.msgbox("Please fill in all fields", false);
return;
}
$.cjax({
url: './api/edit',
method: "POST",
data: {
name: ruleName,
port: newPortNo,
oldPort: portNo
},
success: function(data) {
if (data.error != undefined){
//Error
parent.msgbox(data.error, false);
}else{
//Success
parent.msgbox("Rule updated successfully", true);
initForwardList();
}
}
});
}
//Load forward list
function initForwardList(){
$("#forwardList").html('<tr><td colspan="3"> <i class="ui loading spinner icon"></i> Loading...</tr>');
$.cjax({
url: './api/forward',
success: function(data) {
if (data.error != undefined){
//Error
parent.msgbox(data.error, false);
}else{
//Success
$("#forwardList").empty();
let rows = '';
data.forEach(row => {
$("#forwardList").append(`
<tr>
<td>${row.RuleName}</td>
<td>${row.PortNumber}</td>
<td>
<button onclick="editForwardRule(this);" class="ui basic small circular icon button"><i class="ui edit icon"></i></button>
<button onclick="handleRemoveForward(${row.PortNumber});" class="ui basic small red circular icon button"><i class="ui red trash icon"></i></button>
</td>
</tr>
`);
});
if (data == null || data.length == 0){
//No rules
$("#forwardList").append(`
<tr>
<td colspan="3"><i class="ui green check circle icon"></i> No running port forward rules</td>
</tr>`);
}
}
}
});
}
initUpnpUsableState();
initForwardList();
</script>
</body>
</html>

View File

@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
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
@ -130,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())
}

View File

@ -102,7 +102,7 @@
}
function initGANetID(){
$.get("/api/gan/network/info", function(data){
$.get("./api/gan/network/info", function(data){
if (data.error !== undefined){
msgbox(data.error, false, 5000)
}else{

View File

@ -29,6 +29,7 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/proxy/status", ReverseProxyStatus)
authRouter.HandleFunc("/api/proxy/toggle", ReverseProxyToggleRuleSet)
authRouter.HandleFunc("/api/proxy/list", ReverseProxyList)
authRouter.HandleFunc("/api/proxy/listTags", ReverseProxyListTags)
authRouter.HandleFunc("/api/proxy/detail", ReverseProxyListDetail)
authRouter.HandleFunc("/api/proxy/edit", ReverseProxyHandleEditEndpoint)
authRouter.HandleFunc("/api/proxy/setAlias", ReverseProxyHandleAlias)
@ -225,6 +226,11 @@ func RegisterPluginAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/plugins/enable", pluginManager.HandleEnablePlugin)
authRouter.HandleFunc("/api/plugins/disable", pluginManager.HandleDisablePlugin)
authRouter.HandleFunc("/api/plugins/icon", pluginManager.HandleLoadPluginIcon)
authRouter.HandleFunc("/api/plugins/groups/list", pluginManager.HandleListPluginGroups)
authRouter.HandleFunc("/api/plugins/groups/add", pluginManager.HandleAddPluginToGroup)
authRouter.HandleFunc("/api/plugins/groups/remove", pluginManager.HandleRemovePluginFromGroup)
authRouter.HandleFunc("/api/plugins/groups/deleteTag", pluginManager.HandleRemovePluginGroup)
}
// Register the APIs for Auth functions, due to scoping issue some functions are defined here

101
src/mod/plugins/groups.go Normal file
View File

@ -0,0 +1,101 @@
package plugins
import (
"encoding/json"
"errors"
"os"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
)
// ListPluginGroups returns a map of plugin groups
func (m *Manager) ListPluginGroups() map[string][]string {
pluginGroup := map[string][]string{}
m.Options.pluginGroupsMutex.RLock()
for k, v := range m.Options.PluginGroups {
pluginGroup[k] = append([]string{}, v...)
}
m.Options.pluginGroupsMutex.RUnlock()
return pluginGroup
}
// AddPluginToGroup adds a plugin to a group
func (m *Manager) AddPluginToGroup(tag, pluginID string) error {
//Check if the plugin exists
plugin, ok := m.LoadedPlugins[pluginID]
if !ok {
return errors.New("plugin not found")
}
//Check if the plugin is a router type plugin
if plugin.Spec.Type != zoraxy_plugin.PluginType_Router {
return errors.New("plugin is not a router type plugin")
}
m.Options.pluginGroupsMutex.Lock()
//Check if the tag exists
_, ok = m.Options.PluginGroups[tag]
if !ok {
m.Options.PluginGroups[tag] = []string{pluginID}
m.Options.pluginGroupsMutex.Unlock()
return nil
}
//Add the plugin to the group
m.Options.PluginGroups[tag] = append(m.Options.PluginGroups[tag], pluginID)
m.Options.pluginGroupsMutex.Unlock()
return nil
}
// RemovePluginFromGroup removes a plugin from a group
func (m *Manager) RemovePluginFromGroup(tag, pluginID string) error {
m.Options.pluginGroupsMutex.Lock()
defer m.Options.pluginGroupsMutex.Unlock()
//Check if the tag exists
_, ok := m.Options.PluginGroups[tag]
if !ok {
return errors.New("tag not found")
}
//Remove the plugin from the group
pluginList := m.Options.PluginGroups[tag]
for i, id := range pluginList {
if id == pluginID {
pluginList = append(pluginList[:i], pluginList[i+1:]...)
m.Options.PluginGroups[tag] = pluginList
return nil
}
}
return errors.New("plugin not found")
}
// RemovePluginGroup removes a plugin group
func (m *Manager) RemovePluginGroup(tag string) error {
m.Options.pluginGroupsMutex.Lock()
defer m.Options.pluginGroupsMutex.Unlock()
_, ok := m.Options.PluginGroups[tag]
if !ok {
return errors.New("tag not found")
}
delete(m.Options.PluginGroups, tag)
return nil
}
// SavePluginGroupsFromFile loads plugin groups from a file
func (m *Manager) SavePluginGroupsToFile() error {
m.Options.pluginGroupsMutex.RLock()
pluginGroupsCopy := make(map[string][]string)
for k, v := range m.Options.PluginGroups {
pluginGroupsCopy[k] = append([]string{}, v...)
}
m.Options.pluginGroupsMutex.RUnlock()
//Write to file
js, _ := json.Marshal(pluginGroupsCopy)
err := os.WriteFile(m.Options.PluginGroupsConfig, js, 0644)
if err != nil {
return err
}
return nil
}

View File

@ -11,6 +11,146 @@ import (
"imuslab.com/zoraxy/mod/utils"
)
/* Plugin Groups */
// HandleListPluginGroups handles the request to list all plugin groups
func (m *Manager) HandleListPluginGroups(w http.ResponseWriter, r *http.Request) {
targetTag, err := utils.GetPara(r, "tag")
if err != nil {
//List all tags
pluginGroups := m.ListPluginGroups()
js, _ := json.Marshal(pluginGroups)
utils.SendJSONResponse(w, string(js))
} else {
//List the plugins under the tag
m.tagPluginListMutex.RLock()
plugins, ok := m.tagPluginList[targetTag]
m.tagPluginListMutex.RUnlock()
if !ok {
//Return empty array
js, _ := json.Marshal([]string{})
utils.SendJSONResponse(w, string(js))
return
}
//Sort the plugin by its name
sort.Slice(plugins, func(i, j int) bool {
return plugins[i].Spec.Name < plugins[j].Spec.Name
})
js, err := json.Marshal(plugins)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
utils.SendJSONResponse(w, string(js))
}
}
// HandleAddPluginToGroup handles the request to add a plugin to a group
func (m *Manager) HandleAddPluginToGroup(w http.ResponseWriter, r *http.Request) {
tag, err := utils.PostPara(r, "tag")
if err != nil {
utils.SendErrorResponse(w, "tag not found")
return
}
pluginID, err := utils.PostPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
//Check if plugin exists
_, err = m.GetPluginByID(pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Add the plugin to the group
err = m.AddPluginToGroup(tag, pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Save the plugin groups to file
err = m.SavePluginGroupsToFile()
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Update the radix tree mapping
m.UpdateTagsToPluginMaps()
utils.SendOK(w)
}
// HandleRemovePluginFromGroup handles the request to remove a plugin from a group
func (m *Manager) HandleRemovePluginFromGroup(w http.ResponseWriter, r *http.Request) {
tag, err := utils.PostPara(r, "tag")
if err != nil {
utils.SendErrorResponse(w, "tag not found")
return
}
pluginID, err := utils.PostPara(r, "plugin_id")
if err != nil {
utils.SendErrorResponse(w, "plugin_id not found")
return
}
//Remove the plugin from the group
err = m.RemovePluginFromGroup(tag, pluginID)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Save the plugin groups to file
err = m.SavePluginGroupsToFile()
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Update the radix tree mapping
m.UpdateTagsToPluginMaps()
utils.SendOK(w)
}
// HandleRemovePluginGroup handles the request to remove a plugin group
func (m *Manager) HandleRemovePluginGroup(w http.ResponseWriter, r *http.Request) {
tag, err := utils.PostPara(r, "tag")
if err != nil {
utils.SendErrorResponse(w, "tag not found")
return
}
//Remove the plugin group
err = m.RemovePluginGroup(tag)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Save the plugin groups to file
err = m.SavePluginGroupsToFile()
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
//Update the radix tree mapping
m.UpdateTagsToPluginMaps()
utils.SendOK(w)
}
/* Plugin APIs */
// HandleListPlugins handles the request to list all loaded plugins
func (m *Manager) HandleListPlugins(w http.ResponseWriter, r *http.Request) {
plugins, err := m.ListLoadedPlugins()

View File

@ -2,7 +2,6 @@ package plugins
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@ -53,10 +52,16 @@ func (m *Manager) StartPlugin(pluginID string) error {
return err
}
stdErrPipe, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
//Create a goroutine to handle the STDOUT of the plugin
go func() {
buf := make([]byte, 1)
lineBuf := ""
@ -82,6 +87,48 @@ func (m *Manager) StartPlugin(pluginID string) error {
}
}()
//Create a goroutine to handle the STDERR of the plugin
go func() {
buf := make([]byte, 1)
lineBuf := ""
for {
n, err := stdErrPipe.Read(buf)
if n > 0 {
lineBuf += string(buf[:n])
for {
if idx := strings.IndexByte(lineBuf, '\n'); idx != -1 {
m.handlePluginSTDERR(pluginID, lineBuf[:idx])
lineBuf = lineBuf[idx+1:]
} else {
break
}
}
}
if err != nil {
if err != io.EOF {
m.handlePluginSTDERR(pluginID, lineBuf) // handle any remaining data
}
break
}
}
}()
//Create a goroutine to wait for the plugin to exit
go func() {
err := cmd.Wait()
if err != nil {
//In theory this should not happen except for a crash
m.Log("plugin "+thisPlugin.Spec.ID+" encounted a fatal error. Disabling plugin...", err)
//Set the plugin state to disabled
thisPlugin.Enabled = false
//Generate a new static forwarder radix tree
m.UpdateTagsToPluginMaps()
return
}
}()
//Create a UI forwarder if the plugin has UI
err = m.StartUIHandlerForPlugin(thisPlugin, pluginConfiguration.Port)
if err != nil {
@ -119,8 +166,6 @@ func (m *Manager) StartUIHandlerForPlugin(targetPlugin *Plugin, pluginListeningP
return err
}
fmt.Println("DEBUG: Requesting Plugin UI URL: ", pluginUIURL)
// Generate the plugin subpath to be trimmed
pluginMatchingPath := filepath.ToSlash(filepath.Join("/plugin.ui/"+targetPlugin.Spec.ID+"/")) + "/"
if targetPlugin.Spec.UIPath != "" {
@ -148,6 +193,19 @@ func (m *Manager) handlePluginSTDOUT(pluginID string, line string) {
m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
}
func (m *Manager) handlePluginSTDERR(pluginID string, line string) {
thisPlugin, err := m.GetPluginByID(pluginID)
if err != nil {
return
}
processID := -1
if thisPlugin.process != nil && thisPlugin.process.Process != nil {
// Get the process ID of the plugin
processID = thisPlugin.process.Process.Pid
}
m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
}
// StopPlugin stops a plugin, it is garanteed that the plugin is stopped after this function
func (m *Manager) StopPlugin(pluginID string) error {
thisPlugin, err := m.GetPluginByID(pluginID)

View File

@ -82,6 +82,7 @@ func (m *Manager) LoadPluginsFromDisk() error {
m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil)
// If the plugin was enabled, start it now
fmt.Println("Plugin enabled state", m.GetPluginPreviousEnableState(thisPlugin.Spec.ID))
if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) {
err = m.StartPlugin(thisPlugin.Spec.ID)
if err != nil {
@ -118,11 +119,11 @@ func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) {
// EnablePlugin enables a plugin
func (m *Manager) EnablePlugin(pluginID string) error {
m.Options.Database.Write("plugins", pluginID, true)
err := m.StartPlugin(pluginID)
if err != nil {
return err
}
m.Options.Database.Write("plugins", pluginID, true)
//Generate the static forwarder radix tree
m.UpdateTagsToPluginMaps()
return nil

View File

@ -13,8 +13,6 @@ func (m *Manager) HandleRoute(w http.ResponseWriter, r *http.Request, tags []str
return false
}
return false
//For each tag, check if the request path matches the static capture path //Wait group for the goroutines
var staticRoutehandlers []*Plugin //The handler for the request, can be multiple plugins
var longestPrefixAcrossAlltags string = "" //The longest prefix across all tags

View File

@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@ -74,6 +74,20 @@ func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handl
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
@ -130,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())
}

View File

@ -1005,6 +1005,23 @@ func ReverseProxyListDetail(w http.ResponseWriter, r *http.Request) {
}
}
// List all tags used in the proxy rules
func ReverseProxyListTags(w http.ResponseWriter, r *http.Request) {
results := []string{}
dynamicProxyRouter.ProxyEndpoints.Range(func(key, value interface{}) bool {
thisEndpoint := value.(*dynamicproxy.ProxyEndpoint)
for _, tag := range thisEndpoint.Tags {
if !utils.StringInArray(results, tag) {
results = append(results, tag)
}
}
return true
})
js, _ := json.Marshal(results)
utils.SendJSONResponse(w, string(js))
}
func ReverseProxyList(w http.ResponseWriter, r *http.Request) {
eptype, err := utils.PostPara(r, "type") //Support root and host
if err != nil {

View File

@ -45,6 +45,9 @@
function resizeIframe() {
let iframe = document.getElementById('pluginContextLoader');
let mainMenuHeight = document.getElementById('mainmenu').offsetHeight;
if (mainMenuHeight == 0){
mainMenuHeight = window.innerHeight - 198; //Fallback to window height
}
iframe.style.height = mainMenuHeight + 'px';
}
@ -57,6 +60,4 @@
//On switch over to this page, load info
resizeIframe();
}
initPluginUIView();
</script>

View File

@ -1,3 +1,69 @@
<style>
#selectablePluginList{
max-height: 300px;
overflow-y: auto;
border-radius: 0.4em;
}
#selectablePluginList .item {
cursor: pointer;
padding: 1em;
}
#selectablePluginList .item:hover {
background-color: var(--theme_bg_active);
}
#selectedTagPluginList{
max-height: 300px;
overflow-y: auto;
border-radius: 0.4em;
}
#selectedTagPluginList .item {
padding: 1em;
cursor: pointer;
}
#selectedTagPluginList .item:hover {
background-color: var(--theme_bg_active);
}
.selectablePluginItem{
position: relative;
}
.selectablePluginItem.active{
background-color: var(--theme_bg_active);
}
.selectablePluginItem .selectedIcon{
position: absolute;
right: 0.2em;
bottom: 0.2em;
display:none;
}
.selectablePluginItem.active .selectedIcon{
display: block;
}
.selectedPluginItem{
position: relative;
}
.selectedPluginItem.active{
background-color: var(--theme_bg_active);
}
.selectedPluginItem .selectedIcon{
position: absolute;
right: 0.2em;
bottom: 0.2em;
display:none;
}
.selectedPluginItem.active .selectedIcon{
display: block;
}
</style>
<div class="standardContainer">
<div class="ui basic segment">
<h2>Plugins</h2>
@ -7,6 +73,55 @@
<div class="header">Experimental Feature</div>
<p>This feature is experimental and may not work as expected. Use with caution.</p>
</div>
<h4 class="ui header">
Plugin Map
<div class="sub header">Assigning a plugin to a tag will make the plugin available to the HTTP Proxy rule with the same tag.</div>
</h4>
<div class="ui stackable grid">
<div class="seven wide column">
<!-- Selectable plugin list -->
<div id="selectablePluginList" class="ui relaxed divided list" style="border: 1px solid var(--divider_color);">
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui arrow up icon"></i> Select a tag to view available plugins
</div>
</div>
</div>
<div class="two wide column" style="display: flex; align-items: center; justify-content: center;">
<!-- Add and Remove button -->
<div>
<button id="removeSelectedPluginFromTagBtn" class="ui basic red icon button" title="Remove selected plugin from tag">
<i class="left arrow icon"></i>
</button>
<br>
<button id="addSelectedPluginTotagBtn" class="ui basic green icon button" title="Add selected plugin to tag" style="margin-top: 0.4em;">
<i class="right arrow icon"></i>
</button>
</div>
</div>
<div class="seven wide column">
<!-- Tag / Plugin List -->
<div class="ui fluid selection dropdown" id="pluginTagList">
<input type="hidden" name="tag">
<i class="dropdown icon"></i>
<div class="default text">Select Tag</div>
<div class="menu">
<!-- <div class="item" data-value="tag1">Tag 1</div> -->
</div>
</div>
<button class="ui basic fluid button" onclick="loadTags();" style="margin-top: 0.4em;"><i class="ui green refresh icon"></i> Refresh Tag List</button>
<div class="ui divider"></div>
<div id="selectedTagPluginList" class="ui relaxed divided list" style="border: 1px solid var(--divider_color);">
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui arrow up icon"></i> Select a tag to view assigned plugins
</div>
</div>
</div>
</div>
<div class="ui divider"></div>
<h4 class="ui header">
Plugin List
<div class="sub header">A list of installed plugins and their enable state</div>
</h4>
<table class="ui basic celled table">
<thead>
<tr>
@ -22,11 +137,220 @@
</div>
<script>
var plugin_list = [];
/* Plugin Tag Assignment */
$('#pluginTagList').dropdown();
$('#pluginTagList').on('change', function() {
const selectedTag = $(this).dropdown('get value');
loadPluginsForTag(selectedTag);
});
function loadPluginsForTag(tag) {
$.get(`/api/plugins/groups/list?tag=${tag}`, function(data) {
$("#selectedTagPluginList").html("");
let selectedPluginIDs = [];
data.forEach(plugin => {
$("#selectedTagPluginList").append(`
<div class="item selectedPluginItem" pluginid="${plugin.Spec.id}">
<img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
<div class="content">
<a class="header">${plugin.Spec.name}</a>
<div class="description">${plugin.Spec.description}</div>
</div>
<div class="selectedIcon">
<i class="ui large green circle check icon"></i>
</div>
</div>
`);
selectedPluginIDs.push(plugin.Spec.id);
});
if (data.length == 0){
$("#selectedTagPluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui green circle check icon"></i> No plugins assigned to this tag
</div>
`);
}
//Load the remaining plugins to the selectable list
$("#selectablePluginList").html("");
let selectablePluginCount = 0;
plugin_list.forEach(plugin => {
if (plugin.Spec.type != 0) {
//This is not a router plugin, skip
return;
}
if (!selectedPluginIDs.includes(plugin.Spec.id)) {
$("#selectablePluginList").append(`
<div class="item selectablePluginItem" pluginid="${plugin.Spec.id}">
<img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
<div class="content">
<a class="header">${plugin.Spec.name}</a>
<div class="description">${plugin.Spec.description}</div>
</div>
<div class="selectedIcon">
<i class="ui large green circle check icon"></i>
</div>
</div>
`);
selectablePluginCount++;
}
});
if (selectablePluginCount == 0){
$("#selectablePluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<i class="ui green circle check icon"></i> No plugins available to assign
</div>
`);
}
bindEventsToSelectableItems();
});
}
//Load all the tags from the server
function loadTags(){
$.get(`/api/proxy/listTags`, function(data){
$("#pluginTagList").find(".menu").html("");
if (data.error != undefined){
msgbox(data.error, false);
return;
}
$("#pluginTagList").find(".menu").html("");
data.forEach(tag => {
$("#pluginTagList").find(".menu").append(`
<div class="item" data-value="${tag}">${tag}</div>
`);
});
});
}
loadTags();
//This is used as a dummy function to initialize the selectable plugin list
function initSelectablePluginList(){
$("#selectablePluginList").html("");
$.get(`/api/plugins/list`, function(data){
data.forEach(plugin => {
if (plugin.Spec.type != 0) {
//This is not a router plugin, skip
return;
}
$("#selectablePluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<img class="ui avatar image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}">
<div class="content">
<a class="header">${plugin.Spec.name}</a>
<div class="description">${plugin.Spec.description}</div>
</div>
</div>
`);
});
if (data.length == 0){
$("#selectablePluginList").append(`
<div class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
<p><i class="ui green circle check icon"></i> No plugins available to assign</p>
<p>Plugins can be installed to Zoraxy by placing the plugin files in the <code>./plugins/{plugin_name}/</code> directory.</p>
</div>
`);
}
});
}
initSelectablePluginList();
function bindEventsToSelectableItems(){
$(".selectablePluginItem").on("click", function(){
$(".selectablePluginItem.active").removeClass("active");
$(this).addClass("active");
});
$(".selectedPluginItem").on("click", function(){
$(".selectedPluginItem.active").removeClass("active");
$(this).addClass("active");
});
}
//Bind events for the buttons
function bindTagAssignButtonEvents(){
$("#addSelectedPluginTotagBtn").on("click", function(){
const selectedPlugin = $(".selectablePluginItem.active");
const selectedTag = $("#pluginTagList").dropdown("get value");
if (selectedPlugin.length == 0){
msgbox("Please select a plugin to add", false);
return;
}
if (selectedTag == ""){
msgbox("Please select a tag to add the plugin to", false);
return;
}
const pluginId = selectedPlugin.attr("pluginid");
addPluginToTag(pluginId, selectedTag);
});
$("#removeSelectedPluginFromTagBtn").on("click", function(){
const selectedPlugin = $(".selectedPluginItem.active");
const selectedTag = $("#pluginTagList").dropdown("get value");
if (selectedPlugin.length == 0){
msgbox("Please select a plugin to remove", false);
return;
}
if (selectedTag == ""){
msgbox("Please select a tag to remove the plugin from", false);
return;
}
const pluginId = selectedPlugin.attr("pluginid");
removePluginFromTag(pluginId, selectedTag);
});
}
bindTagAssignButtonEvents();
function addPluginToTag(pluginId, tag){
$.cjax({
url: '/api/plugins/groups/add',
type: 'POST',
data: {plugin_id: pluginId, tag: tag},
success: function(data){
if (data.error != undefined){
msgbox(data.error, false);
}else{
msgbox("Plugin added to tag", true);
}
loadPluginsForTag(tag);
}
});
}
function removePluginFromTag(pluginId, tag){
$.cjax({
url: '/api/plugins/groups/remove',
type: 'POST',
data: {plugin_id: pluginId, tag: tag},
success: function(data){
if (data.error != undefined){
msgbox(data.error, false);
}else{
msgbox("Plugin removed from tag", true);
}
loadPluginsForTag(tag);
}
});
}
/* Plugin List */
//Render the plugin list to Zoraxy homepage side menu
function initPluginSideMenu(){
$.get(`/api/plugins/list`, function(data){
$("#pluginMenu").html("");
let enabledPluginCount = 0;
plugin_list = data;
data.forEach(plugin => {
if (!plugin.Enabled){
return;
@ -55,6 +379,20 @@ function initPluginSideMenu(){
});
});
/* Handling Plugin Manager State, see index.html */
//Callback to be called when the plugin list is updated
if (plugin_manager_state && !plugin_manager_state.initated){
plugin_manager_state.initated = true;
if (plugin_manager_state.initCallback){
plugin_manager_state.initCallback();
}
}
//Callback to be called when the plugin list is updated
if (plugin_manager_state && plugin_manager_state.listUpdateCallback){
plugin_manager_state.listUpdateCallback();
}
});
}
initPluginSideMenu();
@ -119,6 +457,12 @@ function initiatePluginList(){
initiatePluginList();
/* Tag Assignment */
/* Plugin Lifecycle */
function startPlugin(pluginId, btn=undefined){
if (btn) {
$(btn).html('<i class="spinner loading icon"></i> Starting');

View File

@ -209,7 +209,20 @@
<br><br>
<script>
$(".year").text(new Date().getFullYear());
/*
Plugin Manager State
As some initiation must be done before the plugin manager
loaded up the plugin list, this state here tells the plugin
manager to do some actions after the plugin list is initiated
*/
var plugin_manager_state = {
initated: false, //Whether the plugin manager has been initiated
initCallback: undefined, //Callback to be called when the plugin manager is initiated
listUpdateCallback: undefined //Callback to be called when the plugin list is updated
}
/*
Loader function
@ -258,13 +271,22 @@
try {
let parsedData = JSON.parse(tabID);
tabID = parsedData.tabID;
//Open the plugin context window
if (tabID == "pluginContextWindow"){
let pluginID = parsedData.pluginID;
let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
openTabById(tabID, button);
loadPluginUIContextIfAvailable();
if (pluginID == undefined){
//Do not swap page
return;
}
if (!openPluginTabByID(pluginID)){
//Let the plugin manager to load the plugin list first
plugin_manager_state.initCallback = function(){
let pid = pluginID;
openPluginTabByID(pid);
}
}
}
} catch (e) {
console.error("Invalid JSON data:", e);
@ -290,6 +312,17 @@
$('table').tablesort();
});
//Function to open a plugin tab by its plugin id
function openPluginTabByID(pluginID, tabID="pluginContextWindow"){
let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
if (button.length == 0){
return false;
}
openTabById(tabID, button);
loadPluginUIContextIfAvailable();
return true;
}
function logout() {
$.get("/api/auth/logout", function(response) {
if (response === "OK") {
@ -495,6 +528,9 @@
});
$("body").css("overflow", "auto");
}
/* Utilities */
$(".year").text(new Date().getFullYear());
</script>
</body>
</html>