From 45506c8772a48346ff28175386179e3e2c696b10 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Mon, 7 Jul 2025 14:18:10 +0800 Subject: [PATCH] Added cert resolve viewer - Added certificate resolve viewer on HTTP proxy rule editor - Exposed SNI options (wip) - Code optimize --- src/api.go | 2 + src/cert.go | 81 ++ src/config.go | 13 +- src/mod/dynamicproxy/router.go | 47 + src/mod/dynamicproxy/typedef.go | 3 +- src/mod/tlscert/tlscert.go | 105 +- src/reverseproxy.go | 68 +- src/web/components/httprp.html | 107 +- src/web/components/httprp_new.html | 1455 ---------------------------- 9 files changed, 393 insertions(+), 1488 deletions(-) delete mode 100644 src/web/components/httprp_new.html diff --git a/src/api.go b/src/api.go index 203fa01..59c5df4 100644 --- a/src/api.go +++ b/src/api.go @@ -34,6 +34,7 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/proxy/detail", ReverseProxyListDetail) authRouter.HandleFunc("/api/proxy/edit", ReverseProxyHandleEditEndpoint) authRouter.HandleFunc("/api/proxy/setAlias", ReverseProxyHandleAlias) + authRouter.HandleFunc("/api/proxy/setTlsConfig", ReverseProxyHandleSetTlsConfig) authRouter.HandleFunc("/api/proxy/setHostname", ReverseProxyHandleSetHostname) authRouter.HandleFunc("/api/proxy/del", DeleteProxyEndpoint) authRouter.HandleFunc("/api/proxy/updateCredentials", UpdateProxyBasicAuthCredentials) @@ -79,6 +80,7 @@ func RegisterTLSAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/cert/listdomains", handleListDomains) authRouter.HandleFunc("/api/cert/checkDefault", handleDefaultCertCheck) authRouter.HandleFunc("/api/cert/delete", handleCertRemove) + authRouter.HandleFunc("/api/cert/resolve", handleCertTryResolve) } // Register the APIs for Authentication handlers like Forward Auth and OAUTH2 diff --git a/src/cert.go b/src/cert.go index 1fc26ef..8d2a4ed 100644 --- a/src/cert.go +++ b/src/cert.go @@ -360,6 +360,87 @@ func handleCertUpload(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "File upload successful!") } +func handleCertTryResolve(w http.ResponseWriter, r *http.Request) { + // get the domain + domain, err := utils.GetPara(r, "domain") + if err != nil { + utils.SendErrorResponse(w, "invalid domain given") + return + } + + // get the proxy rule, the pass in domain value must be root or matching domain + proxyRule, err := dynamicProxyRouter.GetProxyEndpointById(domain, false) + if err != nil { + //Try to resolve the domain via alias + proxyRule, err = dynamicProxyRouter.GetProxyEndpointByAlias(domain) + if err != nil { + //No matching rule found + utils.SendErrorResponse(w, "proxy rule not found for domain: "+domain) + return + } + } + + // list all the alias domains for this rule + allDomains := []string{proxyRule.RootOrMatchingDomain} + aliasDomains := []string{} + for _, alias := range proxyRule.MatchingDomainAlias { + if alias != "" { + aliasDomains = append(aliasDomains, alias) + allDomains = append(allDomains, alias) + } + } + + // Try to resolve the domain + domainKeyPairs := map[string]string{} + for _, thisDomain := range allDomains { + pubkey, prikey, err := tlsCertManager.GetCertificateByHostname(thisDomain) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + //Make sure pubkey and private key are not empty + if pubkey == "" || prikey == "" { + domainKeyPairs[thisDomain] = "" + } else { + //Store the key pair + keyname := strings.TrimSuffix(filepath.Base(pubkey), filepath.Ext(pubkey)) + if keyname == "localhost" { + //Internal certs like localhost should not be used + //report as "fallback" key + keyname = "fallback certificate" + } + domainKeyPairs[thisDomain] = keyname + } + + } + + //A domain must be UseDNSValidation if it is a wildcard domain or its alias is a wildcard domain + useDNSValidation := strings.HasPrefix(proxyRule.RootOrMatchingDomain, "*") + for _, alias := range aliasDomains { + if strings.HasPrefix(alias, "*") || strings.HasPrefix(domain, "*") { + useDNSValidation = true + } + } + + type CertInfo struct { + Domain string `json:"domain"` + AliasDomains []string `json:"alias_domains"` + DomainKeyPair map[string]string `json:"domain_key_pair"` + UseDNSValidation bool `json:"use_dns_validation"` + } + + result := &CertInfo{ + Domain: proxyRule.RootOrMatchingDomain, + AliasDomains: aliasDomains, + DomainKeyPair: domainKeyPairs, + UseDNSValidation: useDNSValidation, + } + + js, _ := json.Marshal(result) + utils.SendJSONResponse(w, string(js)) +} + // Handle cert remove func handleCertRemove(w http.ResponseWriter, r *http.Request) { domain, err := utils.PostPara(r, "domain") diff --git a/src/config.go b/src/config.go index 62468f8..e3257b3 100644 --- a/src/config.go +++ b/src/config.go @@ -15,6 +15,7 @@ import ( "imuslab.com/zoraxy/mod/dynamicproxy" "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" + "imuslab.com/zoraxy/mod/tlscert" "imuslab.com/zoraxy/mod/utils" ) @@ -59,12 +60,18 @@ func LoadReverseProxyConfig(configFilepath string) error { thisConfigEndpoint.Tags = []string{} } + //Make sure the TLS options are not nil + if thisConfigEndpoint.TlsOptions == nil { + thisConfigEndpoint.TlsOptions = tlscert.GetDefaultHostSpecificTlsBehavior() + } + //Matching domain not set. Assume root if thisConfigEndpoint.RootOrMatchingDomain == "" { thisConfigEndpoint.RootOrMatchingDomain = "/" } - if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeRoot { + switch thisConfigEndpoint.ProxyType { + case dynamicproxy.ProxyTypeRoot: //This is a root config file rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint) if err != nil { @@ -73,7 +80,7 @@ func LoadReverseProxyConfig(configFilepath string) error { dynamicProxyRouter.SetProxyRouteAsRoot(rootProxyEndpoint) - } else if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeHost { + case dynamicproxy.ProxyTypeHost: //This is a host config file readyProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint) if err != nil { @@ -81,7 +88,7 @@ func LoadReverseProxyConfig(configFilepath string) error { } dynamicProxyRouter.AddProxyRouteToRuntime(readyProxyEndpoint) - } else { + default: return errors.New("not supported proxy type") } diff --git a/src/mod/dynamicproxy/router.go b/src/mod/dynamicproxy/router.go index 62807bc..2e484d2 100644 --- a/src/mod/dynamicproxy/router.go +++ b/src/mod/dynamicproxy/router.go @@ -8,6 +8,7 @@ import ( "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/utils" ) /* @@ -105,3 +106,49 @@ func (router *Router) RemoveProxyEndpointByRootname(rootnameOrMatchingDomain str return targetEpt.Remove() } + +// GetProxyEndpointById retrieves a proxy endpoint by its ID from the Router's ProxyEndpoints map. +// It returns the ProxyEndpoint if found, or an error if not found. +func (h *Router) GetProxyEndpointById(searchingDomain string, includeAlias bool) (*ProxyEndpoint, error) { + var found *ProxyEndpoint + h.ProxyEndpoints.Range(func(key, value interface{}) bool { + proxy, ok := value.(*ProxyEndpoint) + if ok && (proxy.RootOrMatchingDomain == searchingDomain || (includeAlias && utils.StringInArray(proxy.MatchingDomainAlias, searchingDomain))) { + found = proxy + return false // stop iteration + } + return true // continue iteration + }) + if found != nil { + return found, nil + } + return nil, errors.New("proxy rule with given id not found") +} + +func (h *Router) GetProxyEndpointByAlias(alias string) (*ProxyEndpoint, error) { + var found *ProxyEndpoint + h.ProxyEndpoints.Range(func(key, value interface{}) bool { + proxy, ok := value.(*ProxyEndpoint) + if !ok { + return true + } + //Also check for wildcard aliases that matches the alias + for _, thisAlias := range proxy.MatchingDomainAlias { + if ok && thisAlias == alias { + found = proxy + return false // stop iteration + } else if ok && strings.HasPrefix(thisAlias, "*") { + //Check if the alias matches a wildcard alias + if strings.HasSuffix(alias, thisAlias[1:]) { + found = proxy + return false // stop iteration + } + } + } + return true // continue iteration + }) + if found != nil { + return found, nil + } + return nil, errors.New("proxy rule with given alias not found") +} diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index 7a5b334..9f6d63f 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -175,7 +175,8 @@ type ProxyEndpoint struct { Disabled bool //If the rule is disabled //Inbound TLS/SSL Related - BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil) + BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil) + TlsOptions *tlscert.HostSpecificTlsBehavior //TLS options for this endpoint, if nil, use global TLS options //Virtual Directories VirtualDirectories []*VirtualDirectoryEndpoint diff --git a/src/mod/tlscert/tlscert.go b/src/mod/tlscert/tlscert.go index 9f1269a..aae4401 100644 --- a/src/mod/tlscert/tlscert.go +++ b/src/mod/tlscert/tlscert.go @@ -20,11 +20,21 @@ type CertCache struct { PriKey string } +type HostSpecificTlsBehavior struct { + DisableSNI bool //If SNI is enabled for this server name + DisableLegacyCertificateMatching bool //If legacy certificate matching is disabled for this server name + EnableAutoHTTPS bool //If auto HTTPS is enabled for this server name + PreferredCertificate string //Preferred certificate for this server name, if empty, use the first matching certificate +} + type Manager struct { CertStore string //Path where all the certs are stored LoadedCerts []*CertCache //A list of loaded certs Logger *logger.Logger //System wide logger for debug mesage - verbal bool + + /* External handlers */ + hostSpecificTlsBehavior func(serverName string) (*HostSpecificTlsBehavior, error) // Function to get host specific TLS behavior, if nil, use global TLS options + verbal bool } //go:embed localhost.pem localhost.key @@ -50,10 +60,11 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, } thisManager := Manager{ - CertStore: certStore, - LoadedCerts: []*CertCache{}, - verbal: verbal, - Logger: logger, + CertStore: certStore, + LoadedCerts: []*CertCache{}, + hostSpecificTlsBehavior: defaultHostSpecificTlsBehavior, //Default to no SNI and no auto HTTPS + verbal: verbal, + Logger: logger, } err := thisManager.UpdateLoadedCertList() @@ -64,6 +75,21 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, return &thisManager, nil } +// Default host specific TLS behavior +// This is used when no specific TLS behavior is defined for a server name +func GetDefaultHostSpecificTlsBehavior() *HostSpecificTlsBehavior { + return &HostSpecificTlsBehavior{ + DisableSNI: false, + DisableLegacyCertificateMatching: false, + EnableAutoHTTPS: false, + PreferredCertificate: "", + } +} + +func defaultHostSpecificTlsBehavior(serverName string) (*HostSpecificTlsBehavior, error) { + return GetDefaultHostSpecificTlsBehavior(), nil +} + // Update domain mapping from file func (m *Manager) UpdateLoadedCertList() error { //Get a list of certificates from file @@ -161,24 +187,11 @@ func (m *Manager) ListCerts() ([]string, error) { // Get a certificate from disk where its certificate matches with the helloinfo func (m *Manager) GetCert(helloInfo *tls.ClientHelloInfo) (*tls.Certificate, error) { - //Check if the domain corrisponding cert exists - pubKey := "./tmp/localhost.pem" - priKey := "./tmp/localhost.key" - - if utils.FileExists(filepath.Join(m.CertStore, helloInfo.ServerName+".pem")) && utils.FileExists(filepath.Join(m.CertStore, helloInfo.ServerName+".key")) { - //Direct hit - pubKey = filepath.Join(m.CertStore, helloInfo.ServerName+".pem") - priKey = filepath.Join(m.CertStore, helloInfo.ServerName+".key") - } else if m.CertMatchExists(helloInfo.ServerName) { - //Use x509 - pubKey, priKey = m.GetCertByX509CNHostname(helloInfo.ServerName) - } else { - //Fallback to legacy method of matching certificates - if m.DefaultCertExists() { - //Use default.pem and default.key - pubKey = filepath.Join(m.CertStore, "default.pem") - priKey = filepath.Join(m.CertStore, "default.key") - } + //Look for the certificate by hostname + pubKey, priKey, err := m.GetCertificateByHostname(helloInfo.ServerName) + if err != nil { + m.Logger.PrintAndLog("tls-router", "Failed to get certificate for "+helloInfo.ServerName, err) + return nil, err } //Load the cert and serve it @@ -190,6 +203,51 @@ func (m *Manager) GetCert(helloInfo *tls.ClientHelloInfo) (*tls.Certificate, err return &cer, nil } +// GetCertificateByHostname returns the certificate and private key for a given hostname +func (m *Manager) GetCertificateByHostname(hostname string) (string, string, error) { + //Check if the domain corrisponding cert exists + pubKey := "./tmp/localhost.pem" + priKey := "./tmp/localhost.key" + + tlsBehavior, err := m.hostSpecificTlsBehavior(hostname) + if err != nil { + tlsBehavior, _ = defaultHostSpecificTlsBehavior(hostname) + } + + if tlsBehavior.DisableSNI && tlsBehavior.PreferredCertificate != "" && + utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem")) && + utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key")) { + //User setup a Preferred certificate, use the preferred certificate directly + pubKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem") + priKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key") + } else { + if !tlsBehavior.DisableLegacyCertificateMatching && + utils.FileExists(filepath.Join(m.CertStore, hostname+".pem")) && + utils.FileExists(filepath.Join(m.CertStore, hostname+".key")) { + //Legacy filename matching, use the file names directly + //This is the legacy method of matching certificates, it will match the file names directly + //This is used for compatibility with Zoraxy v2 setups + pubKey = filepath.Join(m.CertStore, hostname+".pem") + priKey = filepath.Join(m.CertStore, hostname+".key") + } else if !tlsBehavior.DisableSNI && + m.CertMatchExists(hostname) { + //SNI scan match, find the first matching certificate + pubKey, priKey = m.GetCertByX509CNHostname(hostname) + } else if tlsBehavior.EnableAutoHTTPS { + //Get certificate from CA, WIP + //TODO: Implement AutoHTTPS + } else { + //Fallback to legacy method of matching certificates + if m.DefaultCertExists() { + //Use default.pem and default.key + pubKey = filepath.Join(m.CertStore, "default.pem") + priKey = filepath.Join(m.CertStore, "default.key") + } + } + } + return pubKey, priKey, nil +} + // Check if both the default cert public key and private key exists func (m *Manager) DefaultCertExists() bool { return utils.FileExists(filepath.Join(m.CertStore, "default.pem")) && utils.FileExists(filepath.Join(m.CertStore, "default.key")) @@ -220,7 +278,6 @@ func (m *Manager) RemoveCert(domain string) error { //Update the cert list m.UpdateLoadedCertList() - return nil } diff --git a/src/reverseproxy.go b/src/reverseproxy.go index aaccaaf..0054f1e 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -15,6 +15,7 @@ import ( "imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy" "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" "imuslab.com/zoraxy/mod/netutils" + "imuslab.com/zoraxy/mod/tlscert" "imuslab.com/zoraxy/mod/uptime" "imuslab.com/zoraxy/mod/utils" ) @@ -334,7 +335,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { tags = filteredTags var proxyEndpointCreated *dynamicproxy.ProxyEndpoint - if eptype == "host" { + switch eptype { + case "host": rootOrMatchingDomain, err := utils.PostPara(r, "rootname") if err != nil { utils.SendErrorResponse(w, "hostname not defined") @@ -415,7 +417,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { dynamicProxyRouter.AddProxyRouteToRuntime(preparedEndpoint) proxyEndpointCreated = &thisProxyEndpoint - } else if eptype == "root" { + case "root": //Get the default site options and target dsOptString, err := utils.PostPara(r, "defaultSiteOpt") if err != nil { @@ -469,7 +471,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { return } proxyEndpointCreated = &rootRoutingEndpoint - } else { + default: //Invalid eptype utils.SendErrorResponse(w, "invalid endpoint type") return @@ -677,6 +679,65 @@ func ReverseProxyHandleAlias(w http.ResponseWriter, r *http.Request) { utils.SendOK(w) } +func ReverseProxyHandleSetTlsConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + utils.SendErrorResponse(w, "Method not supported") + return + } + + rootnameOrMatchingDomain, err := utils.PostPara(r, "ep") + if err != nil { + utils.SendErrorResponse(w, "Invalid ep given") + return + } + + tlsConfig, err := utils.PostPara(r, "tlsConfig") + if err != nil { + utils.SendErrorResponse(w, "Invalid TLS config given") + return + } + + tlsConfig = strings.TrimSpace(tlsConfig) + if tlsConfig == "" { + utils.SendErrorResponse(w, "TLS config cannot be empty") + return + } + + newTlsConfig := &tlscert.HostSpecificTlsBehavior{} + err = json.Unmarshal([]byte(tlsConfig), newTlsConfig) + if err != nil { + utils.SendErrorResponse(w, "Invalid TLS config given: "+err.Error()) + return + } + + //Load the target endpoint + ept, err := dynamicProxyRouter.LoadProxy(rootnameOrMatchingDomain) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + ept.TlsOptions = newTlsConfig + + //Prepare to replace the current routing rule + readyRoutingRule, err := dynamicProxyRouter.PrepareProxyRoute(ept) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + dynamicProxyRouter.AddProxyRouteToRuntime(readyRoutingRule) + + //Save it to file + err = SaveReverseProxyConfig(ept) + if err != nil { + utils.SendErrorResponse(w, "Failed to save TLS config: "+err.Error()) + return + } + + utils.SendOK(w) +} + func ReverseProxyHandleSetHostname(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { utils.SendErrorResponse(w, "Method not supported") @@ -1015,6 +1076,7 @@ func RemoveProxyBasicAuthExceptionPaths(w http.ResponseWriter, r *http.Request) func ReverseProxyStatus(w http.ResponseWriter, r *http.Request) { js, err := json.Marshal(dynamicProxyRouter) if err != nil { + SystemWideLogger.PrintAndLog("proxy-config", "Unable to marshal status data", err) utils.SendErrorResponse(w, "Unable to marshal status data") return } diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 5310130..1f29a5f 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -338,8 +338,37 @@
-

Work In Progress
- Please use the outer-most menu TLS / SSL tab for now.

+

The table below shows which certificate will be served by Zoraxy when a client request the following hostnames.

+ + + + + + + + + + +
HostnameResolve to Certificate
+
+ + +
+
+ + +
+
+ + +
+
@@ -711,6 +740,66 @@ $("#httpProxyList").find(".editBtn").removeClass("disabled"); } + function saveTlsConfigs(uuid){ + let enableSNI = $("#httprpEditModal .Tls_EnableSNI")[0].checked; + let enableLegacyCertificateMatching = $("#httprpEditModal .Tls_EnableLegacyCertificateMatching")[0].checked; + let enableAutoHTTPS = $("#httprpEditModal .Tls_EnableAutoHTTPS")[0].checked; + let newTlsOption = { + "DisableSNI": !enableSNI, + "DisableLegacyCertificateMatching": !enableLegacyCertificateMatching, + "EnableAutoHTTPS": enableAutoHTTPS + } + $.cjax({ + url: "/api/proxy/setTlsConfig", + method: "POST", + data: { + "ep": uuid, + "tlsConfig": JSON.stringify(newTlsOption) + }, + success: function(data){ + if (data.error !== undefined){ + msgbox(data.error, false, 3000); + }else{ + msgbox("TLS Config updated"); + } + updateTlsResolveList(uuid); + } + }); + } + + function updateTlsResolveList(uuid){ + let editor = $("#httprpEditModalWrapper"); + //Update the TLS resolve list + $.ajax({ + url: "/api/cert/resolve?domain=" + uuid, + method: "GET", + success: function(data) { + // Populate the TLS resolve list + let resolveList = editor.find(".Tls_resolve_list tbody"); + resolveList.empty(); // Clear existing entries + let primaryDomain = data.domain; + let aliasDomains = data.alias_domains || []; + let certMap = data.domain_key_pair; + + // Add primary domain entry + resolveList.append(` + + ${primaryDomain} + ${certMap[primaryDomain] || "Fallback Certificate"} + + `); + aliasDomains.forEach(alias => { + resolveList.append(` + + ${alias} + ${certMap[alias] || "Fallback Certificate"} + + `); + }); + } + }); + } + function saveProxyInlineEdit(uuid){ let editor = $("#httprpEditModal"); @@ -1245,6 +1334,20 @@ editor.find(".RateLimit").off("change").on("change", rateLimitChangeEvent); /* ------------ TLS ------------ */ + updateTlsResolveList(uuid); + editor.find(".Tls_EnableSNI").prop("checked", !subd.TlsOptions.DisableSNI); + editor.find(".Tls_EnableLegacyCertificateMatching").prop("checked", !subd.TlsOptions.DisableLegacyCertificateMatching); + editor.find(".Tls_EnableAutoHTTPS").prop("checked", !!subd.TlsOptions.EnableAutoHTTPS); + + editor.find(".Tls_EnableSNI").off("change").on("change", function() { + saveTlsConfigs(uuid); + }); + editor.find(".Tls_EnableLegacyCertificateMatching").off("change").on("change", function() { + saveTlsConfigs(uuid); + }); + editor.find(".Tls_EnableAutoHTTPS").off("change").on("change", function() { + saveTlsConfigs(uuid); + }); /* ------------ Tags ------------ */ (()=>{ diff --git a/src/web/components/httprp_new.html b/src/web/components/httprp_new.html deleted file mode 100644 index e7b4f4f..0000000 --- a/src/web/components/httprp_new.html +++ /dev/null @@ -1,1455 +0,0 @@ -
-
-

HTTP Proxy

-

Proxy HTTP server with HTTP or HTTPS for multiple hosts. If you are only proxying for one host / domain, use Default Site instead.

-
- -
- -
- - - -
-
- -
- - - - - - - - - - - - - - -
HostDestinationVirtual DirectoryTags
-
- - -

-
- - -
-
-
- -
-
- -
-
-

- -

-
- -
-
-
-
- - -
-
-
-
- -
-
-
- -
- -
-
- - -
-
-
-
- - Advanced Settings -
-
-
- - -
-
-
- - -
-
-
-
-
-
- -
-
-
- -
-
- -
-
- -
- -
- -
-
-

Work In Progress

-
- -
-
- -
- -
- -
- -
- -
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
- -
- - -

-
- - -
-
-
- -
- -
-
- - - -
-
-
-
-
- - -