mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-06-01 13:17:21 +02:00
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:
parent
4a99afa2f0
commit
f8270e46c2
145
example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go
Normal file
145
example/plugins/debugger/mod/zoraxy_plugin/dev_webserver.go
Normal 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())
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
145
example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go
Normal file
145
example/plugins/helloworld/mod/zoraxy_plugin/dev_webserver.go
Normal 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())
|
||||
}
|
@ -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
327
example/plugins/upnp/api.go
Normal 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
|
||||
}
|
13
example/plugins/upnp/go.mod
Normal file
13
example/plugins/upnp/go.mod
Normal 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
|
||||
)
|
17
example/plugins/upnp/go.sum
Normal file
17
example/plugins/upnp/go.sum
Normal 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=
|
BIN
example/plugins/upnp/icon.png
Normal file
BIN
example/plugins/upnp/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
BIN
example/plugins/upnp/icon.psd
Normal file
BIN
example/plugins/upnp/icon.psd
Normal file
Binary file not shown.
194
example/plugins/upnp/main.go
Normal file
194
example/plugins/upnp/main.go
Normal 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
|
||||
}
|
135
example/plugins/upnp/mod/upnpc/upnpc.go
Normal file
135
example/plugins/upnp/mod/upnpc/upnpc.go
Normal 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
|
||||
}
|
19
example/plugins/upnp/mod/zoraxy_plugin/README.txt
Normal file
19
example/plugins/upnp/mod/zoraxy_plugin/README.txt
Normal 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
|
145
example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go
Normal file
145
example/plugins/upnp/mod/zoraxy_plugin/dev_webserver.go
Normal 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())
|
||||
}
|
162
example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go
Normal file
162
example/plugins/upnp/mod/zoraxy_plugin/dynamic_router.go
Normal 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
|
||||
}
|
156
example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go
Normal file
156
example/plugins/upnp/mod/zoraxy_plugin/embed_webserver.go
Normal 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())
|
||||
}
|
105
example/plugins/upnp/mod/zoraxy_plugin/static_router.go
Normal file
105
example/plugins/upnp/mod/zoraxy_plugin/static_router.go
Normal 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)
|
||||
|
||||
}
|
||||
}
|
175
example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go
Normal file
175
example/plugins/upnp/mod/zoraxy_plugin/zoraxy_plugin.go
Normal 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()
|
||||
}
|
1
example/plugins/upnp/upnp.json
Normal file
1
example/plugins/upnp/upnp.json
Normal file
@ -0,0 +1 @@
|
||||
{"ForwardRules":[{"RuleName":"EarlySpring","PortNumber":2016}],"Enabled":false}
|
302
example/plugins/upnp/www/index.html
Normal file
302
example/plugins/upnp/www/index.html
Normal 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>
|
145
example/plugins/ztnc/mod/zoraxy_plugin/dev_webserver.go
Normal file
145
example/plugins/ztnc/mod/zoraxy_plugin/dev_webserver.go
Normal 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())
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
@ -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{
|
||||
|
@ -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
101
src/mod/plugins/groups.go
Normal 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
|
||||
}
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
145
src/mod/plugins/zoraxy_plugin/dev_webserver.go
Normal file
145
src/mod/plugins/zoraxy_plugin/dev_webserver.go
Normal 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())
|
||||
}
|
@ -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())
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
@ -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');
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user