Browse Source

Added wip tagging system

Toby Chui 1 month ago
parent
commit
a23896d866

+ 361 - 356
config.go

@@ -1,356 +1,361 @@
-package main
-
-import (
-	"archive/zip"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"os"
-	"path/filepath"
-	"strconv"
-	"strings"
-	"time"
-
-	"imuslab.com/zoraxy/mod/dynamicproxy"
-	"imuslab.com/zoraxy/mod/dynamicproxy/loadbalance"
-	"imuslab.com/zoraxy/mod/utils"
-)
-
-/*
-	Reverse Proxy Configs
-
-	The following section handle
-	the reverse proxy configs
-*/
-
-type Record struct {
-	ProxyType               string
-	Rootname                string
-	ProxyTarget             string
-	UseTLS                  bool
-	BypassGlobalTLS         bool
-	SkipTlsValidation       bool
-	RequireBasicAuth        bool
-	BasicAuthCredentials    []*dynamicproxy.BasicAuthCredentials
-	BasicAuthExceptionRules []*dynamicproxy.BasicAuthExceptionRule
-}
-
-/*
-Load Reverse Proxy Config from file and append it to current runtime proxy router
-*/
-func LoadReverseProxyConfig(configFilepath string) error {
-	//Load the config file from disk
-	endpointConfig, err := os.ReadFile(configFilepath)
-	if err != nil {
-		return err
-	}
-
-	//Parse it into dynamic proxy endpoint
-	thisConfigEndpoint := dynamicproxy.GetDefaultProxyEndpoint()
-	err = json.Unmarshal(endpointConfig, &thisConfigEndpoint)
-	if err != nil {
-		return err
-	}
-
-	//Matching domain not set. Assume root
-	if thisConfigEndpoint.RootOrMatchingDomain == "" {
-		thisConfigEndpoint.RootOrMatchingDomain = "/"
-	}
-
-	if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeRoot {
-		//This is a root config file
-		rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint)
-		if err != nil {
-			return err
-		}
-
-		dynamicProxyRouter.SetProxyRouteAsRoot(rootProxyEndpoint)
-
-	} else if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeHost {
-		//This is a host config file
-		readyProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint)
-		if err != nil {
-			return err
-		}
-
-		dynamicProxyRouter.AddProxyRouteToRuntime(readyProxyEndpoint)
-	} else {
-		return errors.New("not supported proxy type")
-	}
-
-	SystemWideLogger.PrintAndLog("proxy-config", thisConfigEndpoint.RootOrMatchingDomain+" -> "+loadbalance.GetUpstreamsAsString(thisConfigEndpoint.ActiveOrigins)+" routing rule loaded", nil)
-	return nil
-}
-
-func filterProxyConfigFilename(filename string) string {
-	//Filter out wildcard characters
-	filename = strings.ReplaceAll(filename, "*", "(ST)")
-	filename = strings.ReplaceAll(filename, "?", "(QM)")
-	filename = strings.ReplaceAll(filename, "[", "(OB)")
-	filename = strings.ReplaceAll(filename, "]", "(CB)")
-	filename = strings.ReplaceAll(filename, "#", "(HT)")
-	return filepath.ToSlash(filename)
-}
-
-func SaveReverseProxyConfig(endpoint *dynamicproxy.ProxyEndpoint) error {
-	//Get filename for saving
-	filename := filepath.Join("./conf/proxy/", endpoint.RootOrMatchingDomain+".config")
-	if endpoint.ProxyType == dynamicproxy.ProxyTypeRoot {
-		filename = "./conf/proxy/root.config"
-	}
-
-	filename = filterProxyConfigFilename(filename)
-
-	//Save config to file
-	js, err := json.MarshalIndent(endpoint, "", " ")
-	if err != nil {
-		return err
-	}
-
-	return os.WriteFile(filename, js, 0775)
-}
-
-func RemoveReverseProxyConfig(endpoint string) error {
-	filename := filepath.Join("./conf/proxy/", endpoint+".config")
-	if endpoint == "/" {
-		filename = "./conf/proxy/root.config"
-	}
-
-	filename = filterProxyConfigFilename(filename)
-
-	if !utils.FileExists(filename) {
-		return errors.New("target endpoint not exists")
-	}
-	return os.Remove(filename)
-}
-
-// Get the default root config that point to the internal static web server
-// this will be used if root config is not found (new deployment / missing root.config file)
-func GetDefaultRootConfig() (*dynamicproxy.ProxyEndpoint, error) {
-	//Get the default proxy endpoint
-	rootProxyEndpointConfig := dynamicproxy.GetDefaultProxyEndpoint()
-	rootProxyEndpointConfig.ProxyType = dynamicproxy.ProxyTypeRoot
-	rootProxyEndpointConfig.RootOrMatchingDomain = "/"
-	rootProxyEndpointConfig.ActiveOrigins = []*loadbalance.Upstream{
-		{
-			OriginIpOrDomain:    "127.0.0.1:" + staticWebServer.GetListeningPort(),
-			RequireTLS:          false,
-			SkipCertValidations: false,
-			Weight:              0,
-		},
-	}
-	rootProxyEndpointConfig.DefaultSiteOption = dynamicproxy.DefaultSite_InternalStaticWebServer
-	rootProxyEndpointConfig.DefaultSiteValue = ""
-
-	//Default settings
-	rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&rootProxyEndpointConfig)
-	if err != nil {
-		return nil, err
-	}
-
-	return rootProxyEndpoint, nil
-}
-
-/*
-	Importer and Exporter of Zoraxy proxy config
-*/
-
-func ExportConfigAsZip(w http.ResponseWriter, r *http.Request) {
-	includeSysDBRaw, _ := utils.GetPara(r, "includeDB")
-	includeSysDB := false
-	if includeSysDBRaw == "true" {
-		//Include the system database in backup snapshot
-		//Temporary set it to read only
-		includeSysDB = true
-	}
-
-	// Specify the folder path to be zipped
-	if !utils.FileExists("./conf") {
-		SystemWideLogger.PrintAndLog("Backup", "Configuration folder not found", nil)
-		return
-	}
-	folderPath := "./conf"
-
-	// Set the Content-Type header to indicate it's a zip file
-	w.Header().Set("Content-Type", "application/zip")
-	// Set the Content-Disposition header to specify the file name
-	w.Header().Set("Content-Disposition", "attachment; filename=\"config.zip\"")
-
-	// Create a zip writer
-	zipWriter := zip.NewWriter(w)
-	defer zipWriter.Close()
-
-	// Walk through the folder and add files to the zip
-	err := filepath.Walk(folderPath, func(filePath string, fileInfo os.FileInfo, err error) error {
-		if err != nil {
-			return err
-		}
-
-		if folderPath == filePath {
-			//Skip root folder
-			return nil
-		}
-
-		// Create a new file in the zip
-		if !utils.IsDir(filePath) {
-			zipFile, err := zipWriter.Create(filePath)
-			if err != nil {
-				return err
-			}
-
-			// Open the file on disk
-			file, err := os.Open(filePath)
-			if err != nil {
-				return err
-			}
-			defer file.Close()
-
-			// Copy the file contents to the zip file
-			_, err = io.Copy(zipFile, file)
-			if err != nil {
-				return err
-			}
-		}
-
-		return nil
-	})
-
-	if includeSysDB {
-		//Also zip in the sysdb
-		zipFile, err := zipWriter.Create("sys.db")
-		if err != nil {
-			SystemWideLogger.PrintAndLog("Backup", "Unable to zip sysdb", err)
-			return
-		}
-
-		// Open the file on disk
-		file, err := os.Open("./sys.db")
-		if err != nil {
-			SystemWideLogger.PrintAndLog("Backup", "Unable to open sysdb", err)
-			return
-		}
-		defer file.Close()
-
-		// Copy the file contents to the zip file
-		_, err = io.Copy(zipFile, file)
-		if err != nil {
-			SystemWideLogger.Println(err)
-			return
-		}
-
-	}
-
-	if err != nil {
-		// Handle the error and send an HTTP response with the error message
-		http.Error(w, fmt.Sprintf("Failed to zip folder: %v", err), http.StatusInternalServerError)
-		return
-	}
-}
-
-func ImportConfigFromZip(w http.ResponseWriter, r *http.Request) {
-	// Check if the request is a POST with a file upload
-	if r.Method != http.MethodPost {
-		http.Error(w, "Invalid request method", http.StatusBadRequest)
-		return
-	}
-
-	// Max file size limit (10 MB in this example)
-	r.ParseMultipartForm(10 << 20)
-
-	// Get the uploaded file
-	file, handler, err := r.FormFile("file")
-	if err != nil {
-		http.Error(w, "Failed to retrieve uploaded file", http.StatusInternalServerError)
-		return
-	}
-	defer file.Close()
-
-	if filepath.Ext(handler.Filename) != ".zip" {
-		http.Error(w, "Upload file is not a zip file", http.StatusInternalServerError)
-		return
-	}
-	// Create the target directory to unzip the files
-	targetDir := "./conf"
-	if utils.FileExists(targetDir) {
-		//Backup the old config to old
-		//backupPath := filepath.Dir(*path_conf) + filepath.Base(*path_conf) + ".old_" + strconv.Itoa(int(time.Now().Unix()))
-		//os.Rename(*path_conf, backupPath)
-		os.Rename("./conf", "./conf.old_"+strconv.Itoa(int(time.Now().Unix())))
-	}
-
-	err = os.MkdirAll(targetDir, os.ModePerm)
-	if err != nil {
-		http.Error(w, fmt.Sprintf("Failed to create target directory: %v", err), http.StatusInternalServerError)
-		return
-	}
-
-	// Open the zip file
-	zipReader, err := zip.NewReader(file, handler.Size)
-	if err != nil {
-		http.Error(w, fmt.Sprintf("Failed to open zip file: %v", err), http.StatusInternalServerError)
-		return
-	}
-
-	restoreDatabase := false
-
-	// Extract each file from the zip archive
-	for _, zipFile := range zipReader.File {
-		// Open the file in the zip archive
-		rc, err := zipFile.Open()
-		if err != nil {
-			http.Error(w, fmt.Sprintf("Failed to open file in zip: %v", err), http.StatusInternalServerError)
-			return
-		}
-		defer rc.Close()
-
-		// Create the corresponding file on disk
-		zipFile.Name = strings.ReplaceAll(zipFile.Name, "../", "")
-		fmt.Println("Restoring: " + strings.ReplaceAll(zipFile.Name, "\\", "/"))
-		if zipFile.Name == "sys.db" {
-			//Sysdb replacement. Close the database and restore
-			sysdb.Close()
-			restoreDatabase = true
-		} else if !strings.HasPrefix(strings.ReplaceAll(zipFile.Name, "\\", "/"), "conf/") {
-			//Malformed zip file.
-			http.Error(w, fmt.Sprintf("Invalid zip file structure or version too old"), http.StatusInternalServerError)
-			return
-		}
-
-		//Check if parent dir exists
-		if !utils.FileExists(filepath.Dir(zipFile.Name)) {
-			os.MkdirAll(filepath.Dir(zipFile.Name), 0775)
-		}
-
-		//Create the file
-		newFile, err := os.Create(zipFile.Name)
-		if err != nil {
-			http.Error(w, fmt.Sprintf("Failed to create file: %v", err), http.StatusInternalServerError)
-			return
-		}
-		defer newFile.Close()
-
-		// Copy the file contents from the zip to the new file
-		_, err = io.Copy(newFile, rc)
-		if err != nil {
-			http.Error(w, fmt.Sprintf("Failed to extract file from zip: %v", err), http.StatusInternalServerError)
-			return
-		}
-	}
-
-	// Send a success response
-	w.WriteHeader(http.StatusOK)
-	SystemWideLogger.Println("Configuration restored")
-	fmt.Fprintln(w, "Configuration restored")
-
-	if restoreDatabase {
-		go func() {
-			SystemWideLogger.Println("Database altered. Restarting in 3 seconds...")
-			time.Sleep(3 * time.Second)
-			os.Exit(0)
-		}()
-
-	}
-
-}
+package main
+
+import (
+	"archive/zip"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"imuslab.com/zoraxy/mod/dynamicproxy"
+	"imuslab.com/zoraxy/mod/dynamicproxy/loadbalance"
+	"imuslab.com/zoraxy/mod/utils"
+)
+
+/*
+	Reverse Proxy Configs
+
+	The following section handle
+	the reverse proxy configs
+*/
+
+type Record struct {
+	ProxyType               string
+	Rootname                string
+	ProxyTarget             string
+	UseTLS                  bool
+	BypassGlobalTLS         bool
+	SkipTlsValidation       bool
+	RequireBasicAuth        bool
+	BasicAuthCredentials    []*dynamicproxy.BasicAuthCredentials
+	BasicAuthExceptionRules []*dynamicproxy.BasicAuthExceptionRule
+}
+
+/*
+Load Reverse Proxy Config from file and append it to current runtime proxy router
+*/
+func LoadReverseProxyConfig(configFilepath string) error {
+	//Load the config file from disk
+	endpointConfig, err := os.ReadFile(configFilepath)
+	if err != nil {
+		return err
+	}
+
+	//Parse it into dynamic proxy endpoint
+	thisConfigEndpoint := dynamicproxy.GetDefaultProxyEndpoint()
+	err = json.Unmarshal(endpointConfig, &thisConfigEndpoint)
+	if err != nil {
+		return err
+	}
+
+	//Make sure the tags are not nil
+	if thisConfigEndpoint.Tags == nil {
+		thisConfigEndpoint.Tags = []string{}
+	}
+
+	//Matching domain not set. Assume root
+	if thisConfigEndpoint.RootOrMatchingDomain == "" {
+		thisConfigEndpoint.RootOrMatchingDomain = "/"
+	}
+
+	if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeRoot {
+		//This is a root config file
+		rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint)
+		if err != nil {
+			return err
+		}
+
+		dynamicProxyRouter.SetProxyRouteAsRoot(rootProxyEndpoint)
+
+	} else if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeHost {
+		//This is a host config file
+		readyProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint)
+		if err != nil {
+			return err
+		}
+
+		dynamicProxyRouter.AddProxyRouteToRuntime(readyProxyEndpoint)
+	} else {
+		return errors.New("not supported proxy type")
+	}
+
+	SystemWideLogger.PrintAndLog("proxy-config", thisConfigEndpoint.RootOrMatchingDomain+" -> "+loadbalance.GetUpstreamsAsString(thisConfigEndpoint.ActiveOrigins)+" routing rule loaded", nil)
+	return nil
+}
+
+func filterProxyConfigFilename(filename string) string {
+	//Filter out wildcard characters
+	filename = strings.ReplaceAll(filename, "*", "(ST)")
+	filename = strings.ReplaceAll(filename, "?", "(QM)")
+	filename = strings.ReplaceAll(filename, "[", "(OB)")
+	filename = strings.ReplaceAll(filename, "]", "(CB)")
+	filename = strings.ReplaceAll(filename, "#", "(HT)")
+	return filepath.ToSlash(filename)
+}
+
+func SaveReverseProxyConfig(endpoint *dynamicproxy.ProxyEndpoint) error {
+	//Get filename for saving
+	filename := filepath.Join("./conf/proxy/", endpoint.RootOrMatchingDomain+".config")
+	if endpoint.ProxyType == dynamicproxy.ProxyTypeRoot {
+		filename = "./conf/proxy/root.config"
+	}
+
+	filename = filterProxyConfigFilename(filename)
+
+	//Save config to file
+	js, err := json.MarshalIndent(endpoint, "", " ")
+	if err != nil {
+		return err
+	}
+
+	return os.WriteFile(filename, js, 0775)
+}
+
+func RemoveReverseProxyConfig(endpoint string) error {
+	filename := filepath.Join("./conf/proxy/", endpoint+".config")
+	if endpoint == "/" {
+		filename = "./conf/proxy/root.config"
+	}
+
+	filename = filterProxyConfigFilename(filename)
+
+	if !utils.FileExists(filename) {
+		return errors.New("target endpoint not exists")
+	}
+	return os.Remove(filename)
+}
+
+// Get the default root config that point to the internal static web server
+// this will be used if root config is not found (new deployment / missing root.config file)
+func GetDefaultRootConfig() (*dynamicproxy.ProxyEndpoint, error) {
+	//Get the default proxy endpoint
+	rootProxyEndpointConfig := dynamicproxy.GetDefaultProxyEndpoint()
+	rootProxyEndpointConfig.ProxyType = dynamicproxy.ProxyTypeRoot
+	rootProxyEndpointConfig.RootOrMatchingDomain = "/"
+	rootProxyEndpointConfig.ActiveOrigins = []*loadbalance.Upstream{
+		{
+			OriginIpOrDomain:    "127.0.0.1:" + staticWebServer.GetListeningPort(),
+			RequireTLS:          false,
+			SkipCertValidations: false,
+			Weight:              0,
+		},
+	}
+	rootProxyEndpointConfig.DefaultSiteOption = dynamicproxy.DefaultSite_InternalStaticWebServer
+	rootProxyEndpointConfig.DefaultSiteValue = ""
+
+	//Default settings
+	rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&rootProxyEndpointConfig)
+	if err != nil {
+		return nil, err
+	}
+
+	return rootProxyEndpoint, nil
+}
+
+/*
+	Importer and Exporter of Zoraxy proxy config
+*/
+
+func ExportConfigAsZip(w http.ResponseWriter, r *http.Request) {
+	includeSysDBRaw, _ := utils.GetPara(r, "includeDB")
+	includeSysDB := false
+	if includeSysDBRaw == "true" {
+		//Include the system database in backup snapshot
+		//Temporary set it to read only
+		includeSysDB = true
+	}
+
+	// Specify the folder path to be zipped
+	if !utils.FileExists("./conf") {
+		SystemWideLogger.PrintAndLog("Backup", "Configuration folder not found", nil)
+		return
+	}
+	folderPath := "./conf"
+
+	// Set the Content-Type header to indicate it's a zip file
+	w.Header().Set("Content-Type", "application/zip")
+	// Set the Content-Disposition header to specify the file name, add timestamp to the filename
+	w.Header().Set("Content-Disposition", "attachment; filename=\"zoraxy-config-"+time.Now().Format("2006-01-02-15-04-05")+".zip\"")
+
+	// Create a zip writer
+	zipWriter := zip.NewWriter(w)
+	defer zipWriter.Close()
+
+	// Walk through the folder and add files to the zip
+	err := filepath.Walk(folderPath, func(filePath string, fileInfo os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		if folderPath == filePath {
+			//Skip root folder
+			return nil
+		}
+
+		// Create a new file in the zip
+		if !utils.IsDir(filePath) {
+			zipFile, err := zipWriter.Create(filePath)
+			if err != nil {
+				return err
+			}
+
+			// Open the file on disk
+			file, err := os.Open(filePath)
+			if err != nil {
+				return err
+			}
+			defer file.Close()
+
+			// Copy the file contents to the zip file
+			_, err = io.Copy(zipFile, file)
+			if err != nil {
+				return err
+			}
+		}
+
+		return nil
+	})
+
+	if includeSysDB {
+		//Also zip in the sysdb
+		zipFile, err := zipWriter.Create("sys.db")
+		if err != nil {
+			SystemWideLogger.PrintAndLog("Backup", "Unable to zip sysdb", err)
+			return
+		}
+
+		// Open the file on disk
+		file, err := os.Open("./sys.db")
+		if err != nil {
+			SystemWideLogger.PrintAndLog("Backup", "Unable to open sysdb", err)
+			return
+		}
+		defer file.Close()
+
+		// Copy the file contents to the zip file
+		_, err = io.Copy(zipFile, file)
+		if err != nil {
+			SystemWideLogger.Println(err)
+			return
+		}
+
+	}
+
+	if err != nil {
+		// Handle the error and send an HTTP response with the error message
+		http.Error(w, fmt.Sprintf("Failed to zip folder: %v", err), http.StatusInternalServerError)
+		return
+	}
+}
+
+func ImportConfigFromZip(w http.ResponseWriter, r *http.Request) {
+	// Check if the request is a POST with a file upload
+	if r.Method != http.MethodPost {
+		http.Error(w, "Invalid request method", http.StatusBadRequest)
+		return
+	}
+
+	// Max file size limit (10 MB in this example)
+	r.ParseMultipartForm(10 << 20)
+
+	// Get the uploaded file
+	file, handler, err := r.FormFile("file")
+	if err != nil {
+		http.Error(w, "Failed to retrieve uploaded file", http.StatusInternalServerError)
+		return
+	}
+	defer file.Close()
+
+	if filepath.Ext(handler.Filename) != ".zip" {
+		http.Error(w, "Upload file is not a zip file", http.StatusInternalServerError)
+		return
+	}
+	// Create the target directory to unzip the files
+	targetDir := "./conf"
+	if utils.FileExists(targetDir) {
+		//Backup the old config to old
+		//backupPath := filepath.Dir(*path_conf) + filepath.Base(*path_conf) + ".old_" + strconv.Itoa(int(time.Now().Unix()))
+		//os.Rename(*path_conf, backupPath)
+		os.Rename("./conf", "./conf.old_"+strconv.Itoa(int(time.Now().Unix())))
+	}
+
+	err = os.MkdirAll(targetDir, os.ModePerm)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Failed to create target directory: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	// Open the zip file
+	zipReader, err := zip.NewReader(file, handler.Size)
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Failed to open zip file: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	restoreDatabase := false
+
+	// Extract each file from the zip archive
+	for _, zipFile := range zipReader.File {
+		// Open the file in the zip archive
+		rc, err := zipFile.Open()
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Failed to open file in zip: %v", err), http.StatusInternalServerError)
+			return
+		}
+		defer rc.Close()
+
+		// Create the corresponding file on disk
+		zipFile.Name = strings.ReplaceAll(zipFile.Name, "../", "")
+		fmt.Println("Restoring: " + strings.ReplaceAll(zipFile.Name, "\\", "/"))
+		if zipFile.Name == "sys.db" {
+			//Sysdb replacement. Close the database and restore
+			sysdb.Close()
+			restoreDatabase = true
+		} else if !strings.HasPrefix(strings.ReplaceAll(zipFile.Name, "\\", "/"), "conf/") {
+			//Malformed zip file.
+			http.Error(w, fmt.Sprintf("Invalid zip file structure or version too old"), http.StatusInternalServerError)
+			return
+		}
+
+		//Check if parent dir exists
+		if !utils.FileExists(filepath.Dir(zipFile.Name)) {
+			os.MkdirAll(filepath.Dir(zipFile.Name), 0775)
+		}
+
+		//Create the file
+		newFile, err := os.Create(zipFile.Name)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Failed to create file: %v", err), http.StatusInternalServerError)
+			return
+		}
+		defer newFile.Close()
+
+		// Copy the file contents from the zip to the new file
+		_, err = io.Copy(newFile, rc)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Failed to extract file from zip: %v", err), http.StatusInternalServerError)
+			return
+		}
+	}
+
+	// Send a success response
+	w.WriteHeader(http.StatusOK)
+	SystemWideLogger.Println("Configuration restored")
+	fmt.Fprintln(w, "Configuration restored")
+
+	if restoreDatabase {
+		go func() {
+			SystemWideLogger.Println("Database altered. Restarting in 3 seconds...")
+			time.Sleep(3 * time.Second)
+			os.Exit(0)
+		}()
+
+	}
+
+}

+ 1 - 0
mod/dynamicproxy/typedef.go

@@ -194,6 +194,7 @@ type ProxyEndpoint struct {
 
 	//Internal Logic Elements
 	parent *Router `json:"-"`
+	Tags   []string // Tags for the proxy endpoint
 }
 
 /*

+ 21 - 0
reverseproxy.go

@@ -305,6 +305,15 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	tagStr, _ := utils.PostPara(r, "tags")
+	tags := []string{}
+	if tagStr != "" {
+		tags = strings.Split(tagStr, ",")
+		for i := range tags {
+			tags[i] = strings.TrimSpace(tags[i])
+		}
+	}
+
 	var proxyEndpointCreated *dynamicproxy.ProxyEndpoint
 	if eptype == "host" {
 		rootOrMatchingDomain, err := utils.PostPara(r, "rootname")
@@ -375,6 +384,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
 			// Rate Limit
 			RequireRateLimit: requireRateLimit,
 			RateLimit:        int64(proxyRateLimit),
+
+			Tags: tags,
 		}
 
 		preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint)
@@ -533,6 +544,15 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	tagStr, _ := utils.PostPara(r, "tags")
+	tags := []string{}
+	if tagStr != "" {
+		tags = strings.Split(tagStr, ",")
+		for i := range tags {
+			tags[i] = strings.TrimSpace(tags[i])
+		}
+	}
+
 	//Generate a new proxyEndpoint from the new config
 	newProxyEndpoint := dynamicproxy.CopyEndpoint(targetProxyEntry)
 	newProxyEndpoint.BypassGlobalTLS = bypassGlobalTLS
@@ -557,6 +577,7 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) {
 	newProxyEndpoint.RateLimit = proxyRateLimit
 	newProxyEndpoint.UseStickySession = useStickySession
 	newProxyEndpoint.DisableUptimeMonitor = disbleUtm
+	newProxyEndpoint.Tags = tags
 
 	//Prepare to replace the current routing rule
 	readyRoutingRule, err := dynamicProxyRouter.PrepareProxyRoute(newProxyEndpoint)

+ 139 - 1
web/components/httprp.html

@@ -11,7 +11,47 @@
         .subdEntry td:not(.ignoremw){
             min-width: 200px;
         }
+
+        .httpProxyListTools{
+            width: 100%;
+        }
+
+        .tag-select{
+            cursor: pointer;
+        }
+
+        .tag-select:hover{
+            text-decoration: underline;
+            opacity: 0.8;
+        }
     </style>
+    <div class="httpProxyListTools">
+        <div id="tagFilterDropdown" class="ui floating basic dropdown labeled icon button" style="min-width: 150px;">
+            <i class="filter icon"></i>
+            <span class="text">Filter by tags</span>
+            <div class="menu">
+              <div class="ui icon search input">
+                <i class="search icon"></i>
+                <input type="text" placeholder="Search tags...">
+              </div>
+              <div class="divider"></div>
+                <div class="scrolling menu tagList">
+                    <!--
+                    Example: 
+                    <div class="item">
+                        <div class="ui red empty circular label"></div>
+                        Important
+                    </div>
+                    -->
+                    <!-- Add more tag options dynamically -->
+                </div>
+            </div>
+        </div>
+        <div class="ui small input" style="margin-bottom: 1em; width: 300px;">
+            <input type="text" id="searchInput" placeholder="Quick Search" onkeydown="handleSearchInput(event);" onchange="handleSearchInput(event);" onblur="handleSearchInput(event);">
+        </div>
+    </div>
+    
     <div style="width: 100%; overflow-x: auto; margin-bottom: 1em; min-height: 300px;">
         <table class="ui celled sortable unstackable compact table">
             <thead>
@@ -19,6 +59,7 @@
                     <th>Host</th>
                     <th>Destination</th>
                     <th>Virtual Directory</th>
+                    <th>Tags</th> <!-- New column for tags -->
                     <th style="max-width: 300px;">Advanced Settings</th>
                     <th class="no-sort" style="min-width:150px;">Actions</th>
                 </tr>
@@ -124,6 +165,9 @@
                             </div>
                         </td>
                         <td data-label="" editable="true" datatype="vdir">${vdList}</td>
+                        <td data-label="tags" datatype="tags">
+                            ${subd.Tags.map(tag => `<span class="ui tiny label tag-select" style="background-color: ${getTagColorByName(tag)}; color: ${getTagTextColor(tag)}">${tag}</span>`).join("")}
+                        </td>
                         <td data-label="" editable="true" datatype="advanced" style="width: 350px;">
                             ${subd.AuthenticationProvider.AuthMethod == 0x1?`<i class="ui grey key icon"></i> Basic Auth`:``}
                             ${subd.AuthenticationProvider.AuthMethod == 0x2?`<i class="ui blue key icon"></i> Authelia`:``}
@@ -142,6 +186,7 @@
                         </td>
                     </tr>`);
                 });
+                populateTagFilterDropdown(data);
             }
 
             resolveAccessRuleNameOnHostRPlist();
@@ -285,7 +330,11 @@
                 column.append(`<button class="ui basic tiny button" style="margin-left: 0.4em; margin-top: 0.4em;" onclick="quickEditVdir('${uuid}');">
                     <i class="ui yellow folder icon"></i> Edit Virtual Directories
                 </button>`);
-
+            }else if (datatype == "tags"){
+                column.append(`
+                    <div class="ui divider"></div>
+                    <button class="ui basic compact fluid tiny button" style="margin-left: 0.4em; margin-top: 0.4em;" onclick="editTags('${uuid}');"><i class="ui purple tag icon"></i> Edit tags</button>
+                `);
             }else if (datatype == "advanced"){
                 let authProvider = payload.AuthenticationProvider.AuthMethod;
                 
@@ -457,6 +506,7 @@
         let requireRateLimit = $(row).find(".RequireRateLimit")[0].checked;
         let rateLimit = $(row).find(".RateLimit").val();
         let bypassGlobalTLS = $(row).find(".BypassGlobalTLS")[0].checked;
+        let tags = $("#proxyTags").val().trim();
 
         $.cjax({
             url: "/api/proxy/edit",
@@ -470,6 +520,7 @@
                 "authprovider" :authProviderType,
                 "rate" :requireRateLimit,
                 "ratenum" :rateLimit,
+                "tags": tags,
             },
             success: function(data){
                 if (data.error !== undefined){
@@ -609,4 +660,91 @@
     tabSwitchEventBind["httprp"] = function(){
         listProxyEndpoints();
     }
+
+    /* Tags & Search */
+    function handleSearchInput(event){
+        if (event.key == "Escape"){
+            $("#searchInput").val("");
+        }
+        filterProxyList();
+    }
+
+     // Function to filter the proxy list
+     function filterProxyList() {
+        let searchInput = $("#searchInput").val().toLowerCase();
+        let selectedTag = $("#tagFilterDropdown").dropdown('get value');
+        $("#httpProxyList tr").each(function() {
+            let host = $(this).find("td[data-label='']").text().toLowerCase();
+            let tags = $(this).find("td[data-label='tags']").text().toLowerCase();
+            if ((host.includes(searchInput) || searchInput === "") && (tags.includes(selectedTag) || selectedTag === "")) {
+                $(this).show();
+            } else {
+                $(this).hide();
+            }
+        });
+    }
+
+    // Function to generate a color based on a tag name
+    function getTagColorByName(tagName) {
+        function hashCode(str) {
+            return str.split('').reduce((prevHash, currVal) =>
+                ((prevHash << 5) - prevHash) + currVal.charCodeAt(0), 0);
+        }
+        let hash = hashCode(tagName);
+        let color = '#' + ((hash >> 24) & 0xFF).toString(16).padStart(2, '0') +
+                            ((hash >> 16) & 0xFF).toString(16).padStart(2, '0') +
+                            ((hash >> 8) & 0xFF).toString(16).padStart(2, '0');
+        return color;
+    }
+
+    function getTagTextColor(tagName){
+        let color = getTagColorByName(tagName);
+        let r = parseInt(color.substr(1, 2), 16);
+        let g = parseInt(color.substr(3, 2), 16);
+        let b = parseInt(color.substr(5, 2), 16);
+        let brightness = Math.round(((r * 299) + (g * 587) + (b * 114)) / 1000);
+        return brightness > 125 ? "#000000" : "#ffffff";
+    }
+    
+     // Populate the tag filter dropdown
+     function populateTagFilterDropdown(data) {
+        let tags = new Set();
+        data.forEach(subd => {
+            subd.Tags.forEach(tag => tags.add(tag));
+        });
+        tags = Array.from(tags).sort((a, b) => a.localeCompare(b));
+        let dropdownMenu = $("#tagFilterDropdown .tagList");
+        dropdownMenu.html(`<div class="item tag-select" data-value="">
+            <div class="ui grey empty circular label"></div>
+            All tags
+        </div>`);
+        tags.forEach(tag => {
+            let thisTagColor = getTagColorByName(tag);
+            dropdownMenu.append(`<div class="item tag-select" data-value="${tag}">
+                <div class="ui empty circular label" style="background-color: ${thisTagColor}; border-color: ${thisTagColor};" ></div>
+                ${tag}
+            </div>`);
+        });
+    }
+
+    // Edit tags for a specific endpoint
+    function editTags(uuid){
+        let payload = encodeURIComponent(JSON.stringify({
+            ept: "host",
+            ep: uuid
+        }));
+        showSideWrapper("snippet/tagEditor.html?t=" + Date.now() + "#" + payload);
+    }
+
+    // Initialize the proxy list on page load
+    $(document).ready(function() {
+        listProxyEndpoints();
+
+        // Event listener for clicking on tags
+        $(document).on('click', '.tag-select', function() {
+            let tag = $(this).text().trim();
+            $('#tagFilterDropdown').dropdown('set selected', tag);
+            filterProxyList();
+        });
+    });
 </script>

+ 4 - 4
web/components/redirection.html

@@ -174,8 +174,8 @@
                   <td>${entry.ForwardChildpath?"<i class='ui green checkmark icon'></i>":"<i class='ui red remove icon'></i>"}</td>
                   <td>${entry.StatusCode==307?"Temporary Redirect (307)":"Moved Permanently (301)"}</td>
                   <td>
-                    <button onclick="editRule(this);" payload="${encodedEntry}" title="Edit redirection rule" class="ui mini blue icon basic button redirectEditBtn"><i class="edit icon"></i></button>
-                    <button onclick="deleteRule(this);" rurl="${encodeURIComponent(JSON.stringify(entry.RedirectURL))}" title="Delete redirection rule" class="ui mini red icon basic button"><i class="trash icon"></i></button>
+                    <button onclick="editRule(this);" payload="${encodedEntry}" title="Edit redirection rule" class="ui mini circular icon basic button redirectEditBtn"><i class="edit icon"></i></button>
+                    <button onclick="deleteRule(this);" rurl="${encodeURIComponent(JSON.stringify(entry.RedirectURL))}" title="Delete redirection rule" class="ui mini red circular icon basic button"><i class="trash icon"></i></button>
                   </td>
               </tr>`);
             });
@@ -214,8 +214,8 @@
           <div class="ui radio checkbox"><input type="radio" name="editStatusCode" value="301" ${statusCode == 301 ? "checked" : ""}><label>Moved Permanently (301)</label></div>
         </td>
         <td>
-          <button onclick="saveEditRule(this);" payload="${encodeURIComponent(JSON.stringify(payload))}" class="ui mini green icon basic button"><i class="save icon"></i></button>
-          <button onclick="initRedirectionRuleList();" class="ui mini icon basic button"><i class="cancel icon"></i></button>
+          <button onclick="saveEditRule(this);" payload="${encodeURIComponent(JSON.stringify(payload))}" class="ui small circular green icon basic button"><i class="save icon"></i></button>
+          <button onclick="initRedirectionRuleList();" class="ui small circular icon basic button"><i class="cancel icon"></i></button>
         </td>
       `);
 

+ 8 - 0
web/components/rules.html

@@ -63,6 +63,11 @@
                                         <label>Sticky Session<br><small>Enable stick session on upstream load balancing</small></label>
                                     </div>
                                 </div>
+                                <div class="field">
+                                    <label>Tags</label>
+                                    <input type="text" id="proxyTags" placeholder="e.g. mediaserver, management">
+                                    <small>Comma-separated list of tags for this proxy host.</small>
+                                </div>
                                 <div class="ui horizontal divider">
                                     <i class="ui green lock icon"></i>
                                     Security
@@ -198,6 +203,7 @@
         let skipWebSocketOriginCheck = $("#skipWebsocketOriginCheck")[0].checked;
         let accessRuleToUse = $("#newProxyRuleAccessFilter").val();
         let useStickySessionLB = $("#useStickySessionLB")[0].checked; 
+        let tags = $("#proxyTags").val().trim();
 
         if (rootname.trim() == ""){
             $("#rootname").parent().addClass("error");
@@ -231,6 +237,7 @@
                 cred: JSON.stringify(credentials),
                 access: accessRuleToUse,
                 stickysess: useStickySessionLB,
+                tags: tags,
             },
             success: function(data){
                 if (data.error != undefined){
@@ -239,6 +246,7 @@
                     //Clear old data
                     $("#rootname").val("");
                     $("#proxyDomain").val("");
+                    $("#proxyTags").val("");
                     credentials = [];
                     updateTable();
                     reloadUptimeList();

+ 77 - 0
web/snippet/tagEditor.html

@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="utf-8">
+        <meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
+        <link rel="stylesheet" href="../script/semantic/semantic.min.css">
+        <script src="../script/jquery-3.6.0.min.js"></script>
+        <script src="../script/semantic/semantic.min.js"></script>
+        <script src="../script/utils.js"></script>
+    </head>
+    <body>
+        <div class="ui container">
+            <div class="ui header">
+                <div class="content">
+                    Edit Tags
+                    <div class="sub header" id="epname"></div>
+                </div>
+            </div>
+            <div class="ui divider"></div>
+            <p>Enter tags for this proxy host. Use commas to separate multiple tags.</p>
+            <div class="ui form">
+                <div class="field">
+                    <label>Tags</label>
+                    <input type="text" id="tagsInput" placeholder="e.g. mediaserver, management">
+                </div>
+                <button class="ui basic button" onclick="saveTags();"><i class="ui green save icon"></i> Save</button>
+                <button class="ui basic button" style="float: right;" onclick="parent.hideSideWrapper();"><i class="remove icon"></i> Close</button>
+            </div>
+        </div>
+        <script>
+            let editingEndpoint = {};
+            if (window.location.hash.length > 1){
+                let payloadHash = window.location.hash.substr(1);
+                try{
+                    payloadHash = JSON.parse(decodeURIComponent(payloadHash));
+                    $("#epname").text(payloadHash.ep);
+                    editingEndpoint = payloadHash;
+                    loadTags();
+                }catch(ex){
+                    console.log("Unable to load endpoint data from hash")
+                }
+            }
+
+            function loadTags(){
+                $.get("/api/proxy/detail", { type: "host", epname: editingEndpoint.ep }, function(data){
+                    if (data.error == undefined){
+                        $("#tagsInput").val(data.Tags.join(", "));
+                    } else {
+                        alert(data.error);
+                    }
+                });
+            }
+
+            function saveTags(){
+                let tags = $("#tagsInput").val().trim().split(",").map(tag => tag.trim());
+                console.log(tags);
+                $.cjax({
+                    url: "/api/proxy/edit",
+                    method: "POST",
+                    data: {
+                        type: "host",
+                        rootname: editingEndpoint.ep,
+                        tags: tags.join(",")
+                    },
+                    success: function(data){
+                        if (data.error != undefined){
+                            parent.msgbox(data.error, false);
+                        } else {
+                            parent.msgbox("Tags updated");
+                            parent.hideSideWrapper();
+                        }
+                    }
+                });
+            }
+        </script>
+    </body>
+</html>