From e77f947d1d6874ea3c95d3a3a759ce5fc7ed43fe Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Fri, 10 Oct 2025 14:43:38 +0800 Subject: [PATCH 01/14] Added loopback proxy support - Added support for shortcut loopback setup in local setups --- src/mod/dynamicproxy/Server.go | 1 - src/mod/dynamicproxy/dpcore/dpcore.go | 10 ++++++- src/mod/dynamicproxy/proxyRequestHandler.go | 33 +++++++++++++++++---- src/mod/uptime/uptime.go | 1 - 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/mod/dynamicproxy/Server.go b/src/mod/dynamicproxy/Server.go index e0b16bb..a9b1aa7 100644 --- a/src/mod/dynamicproxy/Server.go +++ b/src/mod/dynamicproxy/Server.go @@ -92,7 +92,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } //Plugin routing - if h.Parent.Option.PluginManager != nil && h.Parent.Option.PluginManager.HandleRoute(w, r, sep.Tags) { //Request handled by subroute return diff --git a/src/mod/dynamicproxy/dpcore/dpcore.go b/src/mod/dynamicproxy/dpcore/dpcore.go index 9b6157e..dcdfbf2 100644 --- a/src/mod/dynamicproxy/dpcore/dpcore.go +++ b/src/mod/dynamicproxy/dpcore/dpcore.go @@ -438,7 +438,15 @@ func (p *ReverseProxy) ProxyHTTPS(rw http.ResponseWriter, req *http.Request) (in if !strings.Contains(host, ":") { host += ":443" } - serverName := req.URL.Hostname() + serverName := "" + //if p.Transport != nil { + // if tr, ok := p.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.ServerName != "" { + // serverName = tr.TLSClientConfig.ServerName + // } + //} + if serverName == "" { + serverName = req.URL.Hostname() + } // Connect with SNI offload tlsConfig := &tls.Config{ diff --git a/src/mod/dynamicproxy/proxyRequestHandler.go b/src/mod/dynamicproxy/proxyRequestHandler.go index c1bb15e..757bc55 100644 --- a/src/mod/dynamicproxy/proxyRequestHandler.go +++ b/src/mod/dynamicproxy/proxyRequestHandler.go @@ -12,6 +12,7 @@ import ( "strings" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + "imuslab.com/zoraxy/mod/dynamicproxy/loadbalance" "imuslab.com/zoraxy/mod/dynamicproxy/rewrite" "imuslab.com/zoraxy/mod/netutils" "imuslab.com/zoraxy/mod/statistic" @@ -95,27 +96,41 @@ func (router *Router) GetProxyEndpointFromHostname(hostname string) *ProxyEndpoi return targetSubdomainEndpoint } -// Clearn URL Path (without the http:// part) replaces // in a URL to / -func (router *Router) clearnURL(targetUrlOPath string) string { - return strings.ReplaceAll(targetUrlOPath, "//", "/") -} - // Rewrite URL rewrite the prefix part of a virtual directory URL with / func (router *Router) rewriteURL(rooturl string, requestURL string) string { rewrittenURL := requestURL rewrittenURL = strings.TrimPrefix(rewrittenURL, strings.TrimSuffix(rooturl, "/")) if strings.Contains(rewrittenURL, "//") { - rewrittenURL = router.clearnURL(rewrittenURL) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") } return rewrittenURL } +// upstreamHostSwap check if this loopback to one of the proxy rule in the system. If yes, do a shortcut target swap +// this prevents unnecessary external DNS lookup and connection, return true if swapped and request is already handled +// by the loopback handler. Only continue if return is false +func (h *ProxyHandler) upstreamHostSwap(w http.ResponseWriter, r *http.Request, selectedUpstream *loadbalance.Upstream) bool { + upstreamHostanme := selectedUpstream.OriginIpOrDomain + if strings.Contains(upstreamHostanme, ":") { + upstreamHostanme = strings.Split(upstreamHostanme, ":")[0] + } + loopbackProxyEndpoint := h.Parent.GetProxyEndpointFromHostname(upstreamHostanme) + if loopbackProxyEndpoint != nil { + //This is a loopback request. Swap the target to the loopback target + //h.Parent.Option.Logger.PrintAndLog("proxy", "Detected a loopback request to self. Swap the target to "+loopbackProxyEndpoint.RootOrMatchingDomain, nil) + h.hostRequest(w, r, loopbackProxyEndpoint) + return true + } + return false +} + // Handle host request func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, target *ProxyEndpoint) { r.Header.Set("X-Forwarded-Host", r.Host) r.Header.Set("X-Forwarded-Server", "zoraxy-"+h.Parent.Option.HostUUID) reqHostname := r.Host + /* Load balancing */ selectedUpstream, err := h.Parent.loadBalancer.GetRequestUpstreamTarget(w, r, target.ActiveOrigins, target.UseStickySession) if err != nil { @@ -125,6 +140,12 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe return } + /* Upstream Host Swap (use to detect loopback to self) */ + if h.upstreamHostSwap(w, r, selectedUpstream) { + //Request handled by the loopback handler + return + } + /* WebSocket automatic proxy */ requestURL := r.URL.String() if r.Header["Upgrade"] != nil && strings.ToLower(r.Header["Upgrade"][0]) == "websocket" { diff --git a/src/mod/uptime/uptime.go b/src/mod/uptime/uptime.go index 7d31da0..1cf897c 100644 --- a/src/mod/uptime/uptime.go +++ b/src/mod/uptime/uptime.go @@ -211,7 +211,6 @@ func getWebsiteStatus(url string) (int, error) { } resp, err := client.Do(req) - //resp, err := client.Get(url) if err != nil { //Try replace the http with https and vise versa rewriteURL := "" From cf2cf18136b3859ac06979c958e179e18e91101d Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Fri, 10 Oct 2025 15:51:00 +0800 Subject: [PATCH 02/14] Added check for loopback proxy enable state - Added check and show 521 if the loopback proxy endpoint is disabled --- src/mod/dynamicproxy/endpoints.go | 5 +++++ src/mod/dynamicproxy/proxyRequestHandler.go | 16 +++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/mod/dynamicproxy/endpoints.go b/src/mod/dynamicproxy/endpoints.go index 9a18fc1..58031c3 100644 --- a/src/mod/dynamicproxy/endpoints.go +++ b/src/mod/dynamicproxy/endpoints.go @@ -272,6 +272,11 @@ func (ep *ProxyEndpoint) Remove() error { return nil } +// Check if the proxy endpoint is enabled +func (ep *ProxyEndpoint) IsEnabled() bool { + return !ep.Disabled +} + // Write changes to runtime without respawning the proxy handler // use prepare -> remove -> add if you change anything in the endpoint // that effects the proxy routing src / dest diff --git a/src/mod/dynamicproxy/proxyRequestHandler.go b/src/mod/dynamicproxy/proxyRequestHandler.go index 757bc55..7d3f669 100644 --- a/src/mod/dynamicproxy/proxyRequestHandler.go +++ b/src/mod/dynamicproxy/proxyRequestHandler.go @@ -111,15 +111,21 @@ func (router *Router) rewriteURL(rooturl string, requestURL string) string { // this prevents unnecessary external DNS lookup and connection, return true if swapped and request is already handled // by the loopback handler. Only continue if return is false func (h *ProxyHandler) upstreamHostSwap(w http.ResponseWriter, r *http.Request, selectedUpstream *loadbalance.Upstream) bool { - upstreamHostanme := selectedUpstream.OriginIpOrDomain - if strings.Contains(upstreamHostanme, ":") { - upstreamHostanme = strings.Split(upstreamHostanme, ":")[0] + upstreamHostname := selectedUpstream.OriginIpOrDomain + if strings.Contains(upstreamHostname, ":") { + upstreamHostname = strings.Split(upstreamHostname, ":")[0] } - loopbackProxyEndpoint := h.Parent.GetProxyEndpointFromHostname(upstreamHostanme) + loopbackProxyEndpoint := h.Parent.GetProxyEndpointFromHostname(upstreamHostname) if loopbackProxyEndpoint != nil { //This is a loopback request. Swap the target to the loopback target //h.Parent.Option.Logger.PrintAndLog("proxy", "Detected a loopback request to self. Swap the target to "+loopbackProxyEndpoint.RootOrMatchingDomain, nil) - h.hostRequest(w, r, loopbackProxyEndpoint) + if loopbackProxyEndpoint.IsEnabled() { + h.hostRequest(w, r, loopbackProxyEndpoint) + } else { + //Endpoint disabled, return 503 + http.ServeFile(w, r, "./web/rperror.html") + h.Parent.logRequest(r, false, 521, "host-http", r.Host, upstreamHostname) + } return true } return false From 2c270640e9c1bd34d659f16eb0e235a97572f7da Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Sat, 11 Oct 2025 21:36:16 +0800 Subject: [PATCH 03/14] Added #843 - Added checkbox for disabling uptime monitor in new proxy --- src/reverseproxy.go | 10 +++- src/web/components/rules.html | 95 +++++++++++++++++++++++++++++------ 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/reverseproxy.go b/src/reverseproxy.go index a978d65..86d5dc5 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -238,6 +238,13 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { bypassGlobalTLS, _ := utils.PostPara(r, "bypassGlobalTLS") if bypassGlobalTLS == "" { bypassGlobalTLS = "false" + + } + + // Enable uptime monitor? + enableUtm, err := utils.PostBool(r, "enableUtm") + if err != nil { + enableUtm = true } useBypassGlobalTLS := bypassGlobalTLS == "true" @@ -410,7 +417,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { RequireRateLimit: requireRateLimit, RateLimit: int64(proxyRateLimit), - Tags: tags, + Tags: tags, + DisableUptimeMonitor: !enableUtm, } preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint) diff --git a/src/web/components/rules.html b/src/web/components/rules.html index 642798f..5a60d14 100644 --- a/src/web/components/rules.html +++ b/src/web/components/rules.html @@ -62,6 +62,13 @@ + +
+
+ + +
@@ -168,22 +175,78 @@
- Domain
- Example of domain matching keyword:
- aroz.org
Any acess requesting aroz.org will be proxy to the IP address below
-
- Subdomain
- Example of subdomain matching keyword:
- s1.aroz.org
Any request starting with s1.aroz.org will be proxy to the IP address below
-
- Wildcard
- Example of wildcard matching keyword:
- *.aroz.org
Any request with a host name matching *.aroz.org will be proxy to the IP address below. Here are some examples.
-
-
www.aroz.org
-
foo.bar.aroz.org
+
+
+ + Matching Keyword Examples +
+
+ Domain
+ Example of domain matching keyword:
+ aroz.org
Any acess requesting aroz.org will be proxy to the IP address below
+
+ Subdomain
+ Example of subdomain matching keyword:
+ s1.aroz.org
Any request starting with s1.aroz.org will be proxy to the IP address below
+
+ Wildcard
+ Example of wildcard matching keyword:
+ *.aroz.org
Any request with a host name matching *.aroz.org will be proxy to the IP address below. Here are some examples.
+
+
www.aroz.org
+
foo.bar.aroz.org
+
+
+
+ +
+ + Remote Target Require TLS +
+
+ Upstream TLS Requirement
+

+ When you enable Proxy Target require TLS Connection, it means the upstream server (the target you are proxying to) requires a secure (HTTPS) connection.
+ This does not affect whether clients connect to this proxy endpoint using HTTP or HTTPS. +

+
+ Example
+ Matching Keyword: mydomain.com
+ Target: example.com:443 (TLS enabled)

+
    +
  • Client connects to mydomain.com (HTTP or HTTPS, depending on your proxy setup)
  • +
  • Proxy forwards requests to example.com:443 using HTTPS
  • +
+ + Use this option if your upstream server only accepts secure connections.
+ If your upstream uses a self-signed certificate, check the Ignore TLS/SSL Verification Error option in Advance Settings. +
+
+ +
+ + What is Sticky Session? +
+
+ Sticky Session (Session Affinity)
+

+ Sticky session ensures that requests from the same client are always forwarded to the same upstream server. This is useful for applications that store session data locally and require the client to consistently connect to the same backend.
+

+
+ How to Add Multiple Upstreams
+
    +
  • Go to HTTP Proxy in the sidebar.
  • +
  • Click Edit on your proxy rule.
  • +
  • Use the Upstreams section to add more upstream endpoints for load balancing.
  • +
+ + Sticky session will only work if you have more than one upstream endpoint configured. + +
-
+
@@ -204,6 +267,7 @@ let accessRuleToUse = $("#newProxyRuleAccessFilter").val(); let useStickySessionLB = $("#useStickySessionLB")[0].checked; let tags = $("#proxyTags").val().trim(); + let enableUtm = $("#enableUtm")[0].checked; if (rootname.trim() == ""){ $("#rootname").parent().addClass("error"); @@ -238,6 +302,7 @@ access: accessRuleToUse, stickysess: useStickySessionLB, tags: tags, + enableUtm: enableUtm, }, success: function(data){ if (data.error != undefined){ From c424b92036f2077aa8ed6fbfd18cae7ffbadc20f Mon Sep 17 00:00:00 2001 From: Zen Wen Date: Mon, 13 Oct 2025 00:16:31 +0800 Subject: [PATCH 04/14] Move function:NormalizeDomain to utils module --- src/mod/acme/acme.go | 2 +- src/mod/acme/utils.go | 47 ------------------------------------------ src/mod/utils/utils.go | 47 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/src/mod/acme/acme.go b/src/mod/acme/acme.go index e38415a..a52d0be 100644 --- a/src/mod/acme/acme.go +++ b/src/mod/acme/acme.go @@ -438,7 +438,7 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ if (domainPara != "") { for _, d := range strings.Split(domainPara, ",") { // Apply normalization on each domain - nd, err := NormalizeDomain(d) + nd, err := utils.NormalizeDomain(d) if err != nil { utils.SendErrorResponse(w, jsonEscape(err.Error())) return diff --git a/src/mod/acme/utils.go b/src/mod/acme/utils.go index 0a2c3e3..b15b355 100644 --- a/src/mod/acme/utils.go +++ b/src/mod/acme/utils.go @@ -7,8 +7,6 @@ import ( "fmt" "os" "time" - "strings" - "unicode" ) // Get the issuer name from pem file @@ -116,48 +114,3 @@ func CertExpireSoon(certBytes []byte, numberOfDays int) bool { } return false } - - -// NormalizeDomain cleans and validates a domain string. -// - Trims spaces around the domain -// - Converts to lowercase -// - Removes trailing dot (FQDN canonicalization) -// - Checks that the domain conforms to standard rules: -// * Each label ≤ 63 characters -// * Only letters, digits, and hyphens -// * Labels do not start or end with a hyphen -// * Full domain ≤ 253 characters -// Returns an empty string if the domain is invalid. -func NormalizeDomain(d string) (string, error) { - d = strings.TrimSpace(d) - d = strings.ToLower(d) - d = strings.TrimSuffix(d, ".") - - if len(d) == 0 { - return "", errors.New("domain is empty") - } - if len(d) > 253 { - return "", errors.New("domain exceeds 253 characters") - } - - labels := strings.Split(d, ".") - for _, label := range labels { - if len(label) == 0 { - return "", errors.New("Domain '" + d + "' not valid: Empty label") - } - if len(label) > 63 { - return "", errors.New("Domain not valid: label exceeds 63 characters") - } - - for i, r := range label { - if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-') { - return "", errors.New("Domain '" + d + "' not valid: Invalid character '" + string(r) + "' in label") - } - if (i == 0 || i == len(label)-1) && r == '-' { - return "", errors.New("Domain '" + d + "' not valid: label '"+ label +"' starts or ends with hyphen") - } - } - } - - return d, nil -} diff --git a/src/mod/utils/utils.go b/src/mod/utils/utils.go index 2fe1ffd..3c80c5f 100644 --- a/src/mod/utils/utils.go +++ b/src/mod/utils/utils.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode" ) /* @@ -199,4 +200,48 @@ func ValidateListeningAddress(address string) bool { } return true -} \ No newline at end of file +} + +// NormalizeDomain cleans and validates a domain string. +// - Trims spaces around the domain +// - Converts to lowercase +// - Removes trailing dot (FQDN canonicalization) +// - Checks that the domain conforms to standard rules: +// * Each label ≤ 63 characters +// * Only letters, digits, and hyphens +// * Labels do not start or end with a hyphen +// * Full domain ≤ 253 characters +// Returns an empty string if the domain is invalid. +func NormalizeDomain(d string) (string, error) { + d = strings.TrimSpace(d) + d = strings.ToLower(d) + d = strings.TrimSuffix(d, ".") + + if len(d) == 0 { + return "", errors.New("domain is empty") + } + if len(d) > 253 { + return "", errors.New("domain exceeds 253 characters") + } + + labels := strings.Split(d, ".") + for _, label := range labels { + if len(label) == 0 { + return "", errors.New("Domain '" + d + "' not valid: Empty label") + } + if len(label) > 63 { + return "", errors.New("Domain not valid: label exceeds 63 characters") + } + + for i, r := range label { + if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-') { + return "", errors.New("Domain '" + d + "' not valid: Invalid character '" + string(r) + "' in label") + } + if (i == 0 || i == len(label)-1) && r == '-' { + return "", errors.New("Domain '" + d + "' not valid: label '"+ label +"' starts or ends with hyphen") + } + } + } + + return d, nil +} From dd610e5f754c6cc970a9dcf50213bb9240ccbb36 Mon Sep 17 00:00:00 2001 From: Zen Wen Date: Mon, 13 Oct 2025 00:23:15 +0800 Subject: [PATCH 05/14] fix #845 --- src/mod/utils/utils.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mod/utils/utils.go b/src/mod/utils/utils.go index 3c80c5f..219ead4 100644 --- a/src/mod/utils/utils.go +++ b/src/mod/utils/utils.go @@ -225,7 +225,12 @@ func NormalizeDomain(d string) (string, error) { } labels := strings.Split(d, ".") - for _, label := range labels { + for index, label := range labels { + if index ==0 { + if len(label) == 1 && label == "*" { + continue + } + } if len(label) == 0 { return "", errors.New("Domain '" + d + "' not valid: Empty label") } From d155ea3795a92abc699e7800c1e27e22a2dd4942 Mon Sep 17 00:00:00 2001 From: Zen Wen Date: Mon, 13 Oct 2025 00:50:28 +0800 Subject: [PATCH 06/14] Linting and formatting --- src/mod/acme/acme.go | 10 ++++------ src/mod/acme/utils.go | 6 ++---- src/mod/utils/utils.go | 17 +++++++++-------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/mod/acme/acme.go b/src/mod/acme/acme.go index a52d0be..334e9c0 100644 --- a/src/mod/acme/acme.go +++ b/src/mod/acme/acme.go @@ -432,18 +432,18 @@ func (a *ACMEHandler) HandleGetExpiredDomains(w http.ResponseWriter, r *http.Req // to renew the certificate, and sends a JSON response indicating the result of the renewal process. func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Request) { domainPara, err := utils.PostPara(r, "domains") - + //Clean each domain cleanedDomains := []string{} - if (domainPara != "") { + if domainPara != "" { for _, d := range strings.Split(domainPara, ",") { // Apply normalization on each domain nd, err := utils.NormalizeDomain(d) if err != nil { utils.SendErrorResponse(w, jsonEscape(err.Error())) return - } - cleanedDomains = append(cleanedDomains, nd) + } + cleanedDomains = append(cleanedDomains, nd) } } @@ -507,7 +507,6 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ dns = true } - // Default propagation timeout is 300 seconds propagationTimeout := 300 if dns { @@ -549,7 +548,6 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ a.Logf("Could not extract SANs from PEM, using domainPara only", err) } - // Extract DNS servers from the request var dnsServers []string dnsServersPara, err := utils.PostPara(r, "dnsServers") diff --git a/src/mod/acme/utils.go b/src/mod/acme/utils.go index b15b355..df05f2e 100644 --- a/src/mod/acme/utils.go +++ b/src/mod/acme/utils.go @@ -40,8 +40,6 @@ func ExtractDomains(certBytes []byte) ([]string, error) { return []string{}, errors.New("decode cert bytes failed") } - - func ExtractIssuerName(certBytes []byte) (string, error) { // Parse the PEM block block, _ := pem.Decode(certBytes) @@ -71,9 +69,9 @@ func ExtractDomainsFromPEM(pemFilePath string) ([]string, error) { certBytes, err := os.ReadFile(pemFilePath) if err != nil { - return nil, err + return nil, err } - domains,err := ExtractDomains(certBytes) + domains, err := ExtractDomains(certBytes) if err != nil { return nil, err } diff --git a/src/mod/utils/utils.go b/src/mod/utils/utils.go index 219ead4..c99de72 100644 --- a/src/mod/utils/utils.go +++ b/src/mod/utils/utils.go @@ -207,16 +207,17 @@ func ValidateListeningAddress(address string) bool { // - Converts to lowercase // - Removes trailing dot (FQDN canonicalization) // - Checks that the domain conforms to standard rules: -// * Each label ≤ 63 characters -// * Only letters, digits, and hyphens -// * Labels do not start or end with a hyphen -// * Full domain ≤ 253 characters +// - Each label ≤ 63 characters +// - Only letters, digits, and hyphens +// - Labels do not start or end with a hyphen +// - Full domain ≤ 253 characters +// // Returns an empty string if the domain is invalid. func NormalizeDomain(d string) (string, error) { d = strings.TrimSpace(d) d = strings.ToLower(d) d = strings.TrimSuffix(d, ".") - + if len(d) == 0 { return "", errors.New("domain is empty") } @@ -226,7 +227,7 @@ func NormalizeDomain(d string) (string, error) { labels := strings.Split(d, ".") for index, label := range labels { - if index ==0 { + if index == 0 { if len(label) == 1 && label == "*" { continue } @@ -239,11 +240,11 @@ func NormalizeDomain(d string) (string, error) { } for i, r := range label { - if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-') { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { return "", errors.New("Domain '" + d + "' not valid: Invalid character '" + string(r) + "' in label") } if (i == 0 || i == len(label)-1) && r == '-' { - return "", errors.New("Domain '" + d + "' not valid: label '"+ label +"' starts or ends with hyphen") + return "", errors.New("Domain '" + d + "' not valid: label '" + label + "' starts or ends with hyphen") } } } From ed1b0ec673a322104ba934566fa7cef5b30cc8a5 Mon Sep 17 00:00:00 2001 From: Zen Wen Date: Mon, 13 Oct 2025 13:16:20 +0800 Subject: [PATCH 07/14] Move function:NormalizeDomain to netutils module --- src/mod/acme/acme.go | 3 +- src/mod/netutils/netutils.go | 53 ++++++++++++++++++++++++++++++++++++ src/mod/utils/utils.go | 51 ---------------------------------- 3 files changed, 55 insertions(+), 52 deletions(-) diff --git a/src/mod/acme/acme.go b/src/mod/acme/acme.go index 334e9c0..54baacb 100644 --- a/src/mod/acme/acme.go +++ b/src/mod/acme/acme.go @@ -27,6 +27,7 @@ import ( "github.com/go-acme/lego/v4/registration" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/info/logger" + "imuslab.com/zoraxy/mod/netutils" "imuslab.com/zoraxy/mod/utils" ) @@ -438,7 +439,7 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ if domainPara != "" { for _, d := range strings.Split(domainPara, ",") { // Apply normalization on each domain - nd, err := utils.NormalizeDomain(d) + nd, err := netutils.NormalizeDomain(d) if err != nil { utils.SendErrorResponse(w, jsonEscape(err.Error())) return diff --git a/src/mod/netutils/netutils.go b/src/mod/netutils/netutils.go index 46ebcd4..6890b99 100644 --- a/src/mod/netutils/netutils.go +++ b/src/mod/netutils/netutils.go @@ -2,10 +2,13 @@ package netutils import ( "encoding/json" + "errors" "fmt" "net" "net/http" "strconv" + "strings" + "unicode" "github.com/likexian/whois" "imuslab.com/zoraxy/mod/utils" @@ -167,3 +170,53 @@ func CheckIfPortOccupied(portNumber int) bool { listener.Close() return false } + +// NormalizeDomain cleans and validates a domain string. +// - Trims spaces around the domain +// - Converts to lowercase +// - Removes trailing dot (FQDN canonicalization) +// - Checks that the domain conforms to standard rules: +// - Each label ≤ 63 characters +// - Only letters, digits, and hyphens +// - Labels do not start or end with a hyphen +// - Full domain ≤ 253 characters +// +// Returns an empty string if the domain is invalid. +func NormalizeDomain(d string) (string, error) { + d = strings.TrimSpace(d) + d = strings.ToLower(d) + d = strings.TrimSuffix(d, ".") + + if len(d) == 0 { + return "", errors.New("domain is empty") + } + if len(d) > 253 { + return "", errors.New("domain exceeds 253 characters") + } + + labels := strings.Split(d, ".") + for index, label := range labels { + if index == 0 { + if len(label) == 1 && label == "*" { + continue + } + } + if len(label) == 0 { + return "", errors.New("Domain '" + d + "' not valid: Empty label") + } + if len(label) > 63 { + return "", errors.New("Domain not valid: label exceeds 63 characters") + } + + for i, r := range label { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { + return "", errors.New("Domain '" + d + "' not valid: Invalid character '" + string(r) + "' in label") + } + if (i == 0 || i == len(label)-1) && r == '-' { + return "", errors.New("Domain '" + d + "' not valid: label '" + label + "' starts or ends with hyphen") + } + } + } + + return d, nil +} diff --git a/src/mod/utils/utils.go b/src/mod/utils/utils.go index c99de72..21d2e40 100644 --- a/src/mod/utils/utils.go +++ b/src/mod/utils/utils.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" "time" - "unicode" ) /* @@ -201,53 +200,3 @@ func ValidateListeningAddress(address string) bool { return true } - -// NormalizeDomain cleans and validates a domain string. -// - Trims spaces around the domain -// - Converts to lowercase -// - Removes trailing dot (FQDN canonicalization) -// - Checks that the domain conforms to standard rules: -// - Each label ≤ 63 characters -// - Only letters, digits, and hyphens -// - Labels do not start or end with a hyphen -// - Full domain ≤ 253 characters -// -// Returns an empty string if the domain is invalid. -func NormalizeDomain(d string) (string, error) { - d = strings.TrimSpace(d) - d = strings.ToLower(d) - d = strings.TrimSuffix(d, ".") - - if len(d) == 0 { - return "", errors.New("domain is empty") - } - if len(d) > 253 { - return "", errors.New("domain exceeds 253 characters") - } - - labels := strings.Split(d, ".") - for index, label := range labels { - if index == 0 { - if len(label) == 1 && label == "*" { - continue - } - } - if len(label) == 0 { - return "", errors.New("Domain '" + d + "' not valid: Empty label") - } - if len(label) > 63 { - return "", errors.New("Domain not valid: label exceeds 63 characters") - } - - for i, r := range label { - if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' { - return "", errors.New("Domain '" + d + "' not valid: Invalid character '" + string(r) + "' in label") - } - if (i == 0 || i == len(label)-1) && r == '-' { - return "", errors.New("Domain '" + d + "' not valid: label '" + label + "' starts or ends with hyphen") - } - } - } - - return d, nil -} From 66572981b388ab5e36735c56f3476a07b472ddce Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Mon, 13 Oct 2025 21:09:29 +0800 Subject: [PATCH 08/14] Optimized TLS list - Optimized TLS certificate list - Added common name auto fill for certificate upload tool - Updated version number - Removed unused code in helper --- src/api.go | 2 + src/def.go | 2 +- src/mod/tlscert/handler.go | 99 +++++++++++++++++++++++++++++-- src/mod/tlscert/helper.go | 19 ++---- src/web/components/cert.html | 110 +++++++++++++++++++++++++++++------ 5 files changed, 195 insertions(+), 37 deletions(-) diff --git a/src/api.go b/src/api.go index 27fc773..d4af088 100644 --- a/src/api.go +++ b/src/api.go @@ -79,6 +79,8 @@ func RegisterTLSAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/cert/setPreferredCertificate", handleSetDomainPreferredCertificate) //Certificate store functions + authRouter.HandleFunc("/api/cert/setDefault", tlsCertManager.SetCertAsDefault) + authRouter.HandleFunc("/api/cert/getCommonName", tlsCertManager.HandleGetCertCommonName) authRouter.HandleFunc("/api/cert/upload", tlsCertManager.HandleCertUpload) authRouter.HandleFunc("/api/cert/download", tlsCertManager.HandleCertDownload) authRouter.HandleFunc("/api/cert/list", tlsCertManager.HandleListCertificate) diff --git a/src/def.go b/src/def.go index 8f7e0cb..01a5909 100644 --- a/src/def.go +++ b/src/def.go @@ -44,7 +44,7 @@ import ( const ( /* Build Constants */ SYSTEM_NAME = "Zoraxy" - SYSTEM_VERSION = "3.2.7" + SYSTEM_VERSION = "3.2.8" DEVELOPMENT_BUILD = false /* System Constants */ diff --git a/src/mod/tlscert/handler.go b/src/mod/tlscert/handler.go index 5e9cc7b..099c6a3 100644 --- a/src/mod/tlscert/handler.go +++ b/src/mod/tlscert/handler.go @@ -75,6 +75,50 @@ func (m *Manager) HandleCertDownload(w http.ResponseWriter, r *http.Request) { } } +// Set the selected certificate as the default / fallback certificate +func (m *Manager) SetCertAsDefault(w http.ResponseWriter, r *http.Request) { + certname, err := utils.PostPara(r, "certname") + if err != nil { + utils.SendErrorResponse(w, "invalid certname given") + return + } + + //Check if the previous default cert exists. If yes, get its hostname from cert contents + defaultPubKey := filepath.Join(m.CertStore, "default.key") + defaultPriKey := filepath.Join(m.CertStore, "default.pem") + if utils.FileExists(defaultPubKey) && utils.FileExists(defaultPriKey) { + //Move the existing default cert to its original name + certBytes, err := os.ReadFile(defaultPriKey) + if err == nil { + block, _ := pem.Decode(certBytes) + if block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err == nil { + os.Rename(defaultPubKey, filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "key"))) + os.Rename(defaultPriKey, filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "pem"))) + } + } + } + } + + //Check if the cert exists + certname = filepath.Base(certname) //prevent path escape + pubKey := filepath.Join(filepath.Join(m.CertStore), certname+".key") + priKey := filepath.Join(filepath.Join(m.CertStore), certname+".pem") + if utils.FileExists(pubKey) && utils.FileExists(priKey) { + os.Rename(pubKey, filepath.Join(m.CertStore, "default.key")) + os.Rename(priKey, filepath.Join(m.CertStore, "default.pem")) + utils.SendOK(w) + + //Update cert list + m.UpdateLoadedCertList() + + } else { + utils.SendErrorResponse(w, "invalid key-pairs: private key or public key not found in key store") + return + } +} + // Handle upload of the certificate func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) { // check if request method is POST @@ -124,6 +168,13 @@ func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) { defer file.Close() // create file in upload directory + // Read file contents for validation + fileBytes, err := io.ReadAll(file) + if err != nil { + http.Error(w, "Failed to read file", http.StatusBadRequest) + return + } + os.MkdirAll(m.CertStore, 0775) f, err := os.Create(filepath.Join(m.CertStore, overWriteFilename)) if err != nil { @@ -138,6 +189,11 @@ func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) { http.Error(w, "Failed to save file", http.StatusInternalServerError) return } + _, err = f.Write(fileBytes) + if err != nil { + http.Error(w, "Failed to save file", http.StatusInternalServerError) + return + } //Update cert list m.UpdateLoadedCertList() @@ -215,11 +271,13 @@ func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request) showDate, _ := utils.GetBool(r, "date") if showDate { type CertInfo struct { - Domain string + Domain string // Domain name of the certificate + Filename string // Filename that stores the certificate LastModifiedDate string ExpireDate string RemainingDays int - UseDNS bool + UseDNS bool // Whether this cert is obtained via DNS challenge + IsFallback bool // Whether this cert is the fallback/default cert } results := []*CertInfo{} @@ -248,7 +306,7 @@ func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request) if err == nil { certExpireTime = cert.NotAfter.Format("2006-01-02 15:04:05") - duration := cert.NotAfter.Sub(time.Now()) + duration := time.Until(cert.NotAfter) // Convert the duration to days expiredIn = int(duration.Hours() / 24) @@ -262,12 +320,23 @@ func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request) useDNSValidation = certInfo.UseDNS } + certDomain := "" + block, _ := pem.Decode(certBtyes) + if block != nil { + cert, err := x509.ParseCertificate(block.Bytes) + if err == nil { + certDomain = cert.Subject.CommonName + } + } + thisCertInfo := CertInfo{ - Domain: filename, + Domain: certDomain, + Filename: filename, LastModifiedDate: modifiedTime, ExpireDate: certExpireTime, RemainingDays: expiredIn, UseDNS: useDNSValidation, + IsFallback: (filename == "default"), //TODO: figure out a better implementation } results = append(results, &thisCertInfo) @@ -350,3 +419,25 @@ func (m *Manager) HandleSelfSignCertGenerate(w http.ResponseWriter, r *http.Requ } utils.SendOK(w) } + +// Extract the common name from a PEM encoded certificate +func (m *Manager) HandleGetCertCommonName(w http.ResponseWriter, r *http.Request) { + certContents, err := utils.PostPara(r, "cert") + if err != nil { + utils.SendErrorResponse(w, "Certificate content not provided") + return + } + block, _ := pem.Decode([]byte(certContents)) + if block == nil { + utils.SendErrorResponse(w, "Failed to decode PEM block") + return + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + utils.SendErrorResponse(w, "Failed to parse certificate: "+err.Error()) + return + } + + js, _ := json.Marshal(cert.Subject.CommonName) + utils.SendJSONResponse(w, string(js)) +} diff --git a/src/mod/tlscert/helper.go b/src/mod/tlscert/helper.go index 0704723..31edfa6 100644 --- a/src/mod/tlscert/helper.go +++ b/src/mod/tlscert/helper.go @@ -29,21 +29,6 @@ func getCertPairs(certFiles []string) []string { return result } -// Get the cloest subdomain certificate from a list of domains -func matchClosestDomainCertificate(subdomain string, domains []string) string { - var matchingDomain string = "" - maxLength := 0 - - for _, domain := range domains { - if strings.HasSuffix(subdomain, "."+domain) && len(domain) > maxLength { - matchingDomain = domain - maxLength = len(domain) - } - } - - return matchingDomain -} - // Convert a domain name to a filename format func domainToFilename(domain string, ext string) string { // Replace wildcard '*' with '_' @@ -52,6 +37,10 @@ func domainToFilename(domain string, ext string) string { domain = "_" + strings.TrimPrefix(domain, "*") } + if strings.HasPrefix(".", ext) { + ext = strings.TrimPrefix(ext, ".") + } + // Add .pem extension ext = strings.TrimPrefix(ext, ".") // Ensure ext does not start with a dot return domain + "." + ext diff --git a/src/web/components/cert.html b/src/web/components/cert.html index bb35c34..7063b29 100644 --- a/src/web/components/cert.html +++ b/src/web/components/cert.html @@ -7,7 +7,12 @@ .valid.certdate{ color: #31c071; } + + #certifiedDomainList .ui.basic.button{ + margin-top: 0.1em; + } +

TLS / SSL Certificates

@@ -21,7 +26,7 @@
- + Match the server name with your CN/DNS entry in certificate for faster resolve time
@@ -59,23 +64,25 @@

Current list of loaded certificates

-
-
+
+
- - - - - - + + + + + + + +
DomainLast UpdateExpire AtDNS ChallengeRenewRemove
DomainFilenameLast UpdateExpire AtFallbackDNS ChallengeActions
- +
@@ -103,6 +110,8 @@
+
We will be removing the fallback certificate section soon.
+ Please use "Set Fallback" button in the certificate list above to set the fallback certificate.
@@ -150,6 +159,58 @@ $("#defaultCA").dropdown(); + function getPossibleCommonNameFromSelectedCertificate(){ + const fileInput = document.getElementById('pubkeySelector'); + const file = fileInput.files[0]; + if (!file) { + msgbox("No certificate file selected", false, 4000); + return; + } + const reader = new FileReader(); + reader.onload = function(e) { + const certContent = e.target.result; + $.cjax({ + url: '/api/cert/getCommonName', + method: 'POST', + data: { cert: certContent }, + success: function(data) { + if (data.error !== undefined) { + //Ignore error + $("#certdomain").attr("placeholder", ""); + } else if (data) { + if (typeof data === "string" && data.startsWith("*.")) { + data = data.substring(2); + } + $("#certdomain").attr("placeholder", data); + } + }, + error: function(xhr) { + //Ignore error + } + }); + }; + reader.readAsText(file); + } + + function setSelectedCertAsFallbackCertificate(certDomain){ + $.cjax({ + url: '/api/cert/setDefault', + method: 'POST', + data: { certname: certDomain }, + success: function(data) { + if (data.error !== undefined) { + msgbox(data.error, false, 5000); + } else { + msgbox('Fallback certificate set successfully!'); + initManagedDomainCertificateList(); + initDefaultKeypairCheck(); + } + }, + error: function(xhr) { + msgbox('Failed to set fallback certificate', false, 5000); + } + }); + } //Renew certificate by button press function renewCertificate(domain, dns, btn=undefined){ @@ -378,17 +439,22 @@ }); data.forEach(entry => { let isExpired = entry.RemainingDays <= 0; - let entryDomainRenewKey = entry.Domain; + let entryDomainRenewKey = entry.Filename; if (entryDomainRenewKey.includes("_.")){ entryDomainRenewKey = entryDomainRenewKey.replace("_.","*."); } $("#certifiedDomainList").append(` - ${entry.Domain} + ${entry.Domain} + ${entry.Filename} ${entry.LastModifiedDate} ${entry.ExpireDate} (${!isExpired?entry.RemainingDays+" days left":"Expired"}) + ${entry.IsFallback?"":""} - - + + + + + `); }); @@ -413,7 +479,7 @@ document.getElementById('pubkeySelector').value = ''; document.getElementById('prikeySelector').value = ''; document.getElementById('certdomain').value = ''; - + $("#certdomain").attr("placeholder", ""); uploadPendingPublicKey = undefined; uploadPendingPrivateKey = undefined; @@ -439,8 +505,17 @@ function handleDomainKeysUpload(callback=undefined){ let domain = $("#certdomain").val(); if (domain.trim() == ""){ - msgbox("Missing domain", false, 5000); - return; + //Check if placeholder has value + if ($("#certdomain").attr("placeholder").trim() != ""){ + domain = $("#certdomain").attr("placeholder").trim(); + }else{ + domain = undefined; + } + + if (domain == undefined || domain.trim() == "") { + msgbox("Missing domain", false, 5000); + return; + } } if (uploadPendingPublicKey && uploadPendingPrivateKey && typeof uploadPendingPublicKey === 'object' && typeof uploadPendingPrivateKey === 'object') { const publicKeyForm = new FormData(); @@ -493,6 +568,7 @@ const file = event.target.files[0]; if (ktype == "pub"){ uploadPendingPublicKey = file; + getPossibleCommonNameFromSelectedCertificate(); }else if (ktype == "pri"){ uploadPendingPrivateKey = file; } From 8d29a929f729a3f8c1a02f883b9a73fb91eb3e6d Mon Sep 17 00:00:00 2001 From: jemmy1794 Date: Tue, 14 Oct 2025 21:30:32 +0800 Subject: [PATCH 09/14] Add support for Proxy Protocol V1 and V2 in streamproxy configuration - Updated HTML form to allow selection of Proxy Protocol version. - Backend and config struct now use ProxyProtocolVersion (int) instead of UseProxyProtocol (bool). - UI and API now pass and display Proxy Protocol version. TODO: UDP should only allow Proxy Protocol V2 --- src/go.mod | 1 + src/go.sum | 2 + src/mod/streamproxy/handler.go | 38 +++++++++---------- src/mod/streamproxy/streamproxy.go | 60 +++++++++++++++--------------- src/mod/streamproxy/tcpprox.go | 22 ++++++----- src/web/components/streamprox.html | 36 +++++++++--------- 6 files changed, 82 insertions(+), 77 deletions(-) diff --git a/src/go.mod b/src/go.mod index eab53e0..6d354b2 100644 --- a/src/go.mod +++ b/src/go.mod @@ -17,6 +17,7 @@ require ( github.com/likexian/whois v1.15.1 github.com/microcosm-cc/bluemonday v1.0.26 github.com/monperrus/crawler-user-agents v1.1.0 + github.com/pires/go-proxyproto v0.8.1 github.com/shirou/gopsutil/v4 v4.25.1 github.com/stretchr/testify v1.10.0 github.com/syndtr/goleveldb v1.0.0 diff --git a/src/go.sum b/src/go.sum index 9e8da09..dd2019c 100644 --- a/src/go.sum +++ b/src/go.sum @@ -610,6 +610,8 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/peterhellberg/link v1.2.0 h1:UA5pg3Gp/E0F2WdX7GERiNrPQrM1K6CVJUUWfHa4t6c= github.com/peterhellberg/link v1.2.0/go.mod h1:gYfAh+oJgQu2SrZHg5hROVRQe1ICoK0/HHJTcE0edxc= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/src/mod/streamproxy/handler.go b/src/mod/streamproxy/handler.go index 147100e..33faa66 100644 --- a/src/mod/streamproxy/handler.go +++ b/src/mod/streamproxy/handler.go @@ -47,19 +47,19 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) { useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") - useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") + ProxyProtocolVersion, _ := utils.PostInt(r, "proxyProtocolVersion") enableLogging, _ := utils.PostBool(r, "enableLogging") //Create the target config newConfigUUID := m.NewConfig(&ProxyRelayOptions{ - Name: name, - ListeningAddr: strings.TrimSpace(listenAddr), - ProxyAddr: strings.TrimSpace(proxyAddr), - Timeout: timeout, - UseTCP: useTCP, - UseUDP: useUDP, - UseProxyProtocol: useProxyProtocol, - EnableLogging: enableLogging, + Name: name, + ListeningAddr: strings.TrimSpace(listenAddr), + ProxyAddr: strings.TrimSpace(proxyAddr), + Timeout: timeout, + UseTCP: useTCP, + UseUDP: useUDP, + ProxyProtocolVersion: ProxyProtocolVersion, + EnableLogging: enableLogging, }) js, _ := json.Marshal(newConfigUUID) @@ -79,7 +79,7 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) proxyAddr, _ := utils.PostPara(r, "proxyAddr") useTCP, _ := utils.PostBool(r, "useTCP") useUDP, _ := utils.PostBool(r, "useUDP") - useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol") + proxyProtocolVersion, _ := utils.PostInt(r, "proxyProtocolVersion") enableLogging, _ := utils.PostBool(r, "enableLogging") newTimeoutStr, _ := utils.PostPara(r, "timeout") @@ -94,15 +94,15 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request) // Create a new ProxyRuleUpdateConfig with the extracted parameters newConfig := &ProxyRuleUpdateConfig{ - InstanceUUID: configUUID, - NewName: newName, - NewListeningAddr: listenAddr, - NewProxyAddr: proxyAddr, - UseTCP: useTCP, - UseUDP: useUDP, - UseProxyProtocol: useProxyProtocol, - EnableLogging: enableLogging, - NewTimeout: newTimeout, + InstanceUUID: configUUID, + NewName: newName, + NewListeningAddr: listenAddr, + NewProxyAddr: proxyAddr, + UseTCP: useTCP, + UseUDP: useUDP, + ProxyProtocolVersion: proxyProtocolVersion, + EnableLogging: enableLogging, + NewTimeout: newTimeout, } // Call the EditConfig method to modify the configuration diff --git a/src/mod/streamproxy/streamproxy.go b/src/mod/streamproxy/streamproxy.go index c473e33..428ca54 100644 --- a/src/mod/streamproxy/streamproxy.go +++ b/src/mod/streamproxy/streamproxy.go @@ -23,42 +23,42 @@ import ( */ type ProxyRelayOptions struct { - Name string - ListeningAddr string - ProxyAddr string - Timeout int - UseTCP bool - UseUDP bool - UseProxyProtocol bool - EnableLogging bool + Name string + ListeningAddr string + ProxyAddr string + Timeout int + UseTCP bool + UseUDP bool + ProxyProtocolVersion int + EnableLogging bool } // ProxyRuleUpdateConfig is used to update the proxy rule config type ProxyRuleUpdateConfig struct { - InstanceUUID string //The target instance UUID to update - NewName string //New name for the instance, leave empty for no change - NewListeningAddr string //New listening address, leave empty for no change - NewProxyAddr string //New proxy target address, leave empty for no change - UseTCP bool //Enable TCP proxy, default to false - UseUDP bool //Enable UDP proxy, default to false - UseProxyProtocol bool //Enable Proxy Protocol, default to false - EnableLogging bool //Enable Logging TCP/UDP Message, default to true - NewTimeout int //New timeout for the connection, leave -1 for no change + InstanceUUID string //The target instance UUID to update + NewName string //New name for the instance, leave empty for no change + NewListeningAddr string //New listening address, leave empty for no change + NewProxyAddr string //New proxy target address, leave empty for no change + UseTCP bool //Enable TCP proxy, default to false + UseUDP bool //Enable UDP proxy, default to false + ProxyProtocolVersion int //Enable Proxy Protocol v1/v2, default to disabled + EnableLogging bool //Enable Logging TCP/UDP Message, default to true + NewTimeout int //New timeout for the connection, leave -1 for no change } type ProxyRelayInstance struct { /* Runtime Config */ - UUID string //A UUIDv4 representing this config - Name string //Name of the config - Running bool //Status, read only - AutoStart bool //If the service suppose to started automatically - ListeningAddress string //Listening Address, usually 127.0.0.1:port - ProxyTargetAddr string //Proxy target address - UseTCP bool //Enable TCP proxy - UseUDP bool //Enable UDP proxy - UseProxyProtocol bool //Enable Proxy Protocol - EnableLogging bool //Enable logging for ProxyInstance - Timeout int //Timeout for connection in sec + UUID string //A UUIDv4 representing this config + Name string //Name of the config + Running bool //Status, read only + AutoStart bool //If the service suppose to started automatically + ListeningAddress string //Listening Address, usually 127.0.0.1:port + ProxyTargetAddr string //Proxy target address + UseTCP bool //Enable TCP proxy + UseUDP bool //Enable UDP proxy + ProxyProtocolVersion int //Proxy Protocol v1/v2 + EnableLogging bool //Enable logging for ProxyInstance + Timeout int //Timeout for connection in sec /* Internal */ tcpStopChan chan bool //Stop channel for TCP listener @@ -178,7 +178,7 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string { ProxyTargetAddr: config.ProxyAddr, UseTCP: config.UseTCP, UseUDP: config.UseUDP, - UseProxyProtocol: config.UseProxyProtocol, + ProxyProtocolVersion: config.ProxyProtocolVersion, EnableLogging: config.EnableLogging, Timeout: config.Timeout, tcpStopChan: nil, @@ -224,7 +224,7 @@ func (m *Manager) EditConfig(newConfig *ProxyRuleUpdateConfig) error { foundConfig.UseTCP = newConfig.UseTCP foundConfig.UseUDP = newConfig.UseUDP - foundConfig.UseProxyProtocol = newConfig.UseProxyProtocol + foundConfig.ProxyProtocolVersion = newConfig.ProxyProtocolVersion foundConfig.EnableLogging = newConfig.EnableLogging if newConfig.NewTimeout != -1 { diff --git a/src/mod/streamproxy/tcpprox.go b/src/mod/streamproxy/tcpprox.go index d16ad8d..b102807 100644 --- a/src/mod/streamproxy/tcpprox.go +++ b/src/mod/streamproxy/tcpprox.go @@ -11,6 +11,8 @@ import ( "sync" "sync/atomic" "time" + + proxyproto "github.com/pires/go-proxyproto" ) func isValidIP(ip string) bool { @@ -44,20 +46,22 @@ func (c *ProxyRelayInstance) connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.W wg.Done() } -func writeProxyProtocolHeaderV1(dst net.Conn, src net.Conn) error { +func WriteProxyProtocolHeader(dst net.Conn, src net.Conn, version int) error { clientAddr, ok1 := src.RemoteAddr().(*net.TCPAddr) proxyAddr, ok2 := src.LocalAddr().(*net.TCPAddr) if !ok1 || !ok2 { return errors.New("invalid TCP address for proxy protocol") } - header := fmt.Sprintf("PROXY TCP4 %s %s %d %d\r\n", - clientAddr.IP.String(), - proxyAddr.IP.String(), - clientAddr.Port, - proxyAddr.Port) + header := proxyproto.Header{ + Version: byte(version), + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.TCPv4, + SourceAddr: clientAddr, + DestinationAddr: proxyAddr, + } - _, err := dst.Write([]byte(header)) + _, err := header.WriteTo(dst) return err } @@ -161,9 +165,9 @@ func (c *ProxyRelayInstance) Port2host(allowPort string, targetAddress string, s } c.LogMsg("[→] connect target address ["+targetAddress+"] success.", nil) - if c.UseProxyProtocol { + if c.ProxyProtocolVersion != 0 { c.LogMsg("[+] write proxy protocol header to target address ["+targetAddress+"]", nil) - err = writeProxyProtocolHeaderV1(target, conn) + err = WriteProxyProtocolHeader(target, conn, c.ProxyProtocolVersion) if err != nil { c.LogMsg("[x] Write proxy protocol header failed: "+err.Error(), nil) target.Close() diff --git a/src/web/components/streamprox.html b/src/web/components/streamprox.html index bddac54..3746a9b 100644 --- a/src/web/components/streamprox.html +++ b/src/web/components/streamprox.html @@ -74,14 +74,6 @@ Forward UDP request on this listening socket
-
-
- - -
-
@@ -90,6 +82,15 @@
+
+ + + Select Proxy Protocol v1 / v2 to use (if any) +
@@ -138,7 +139,7 @@ function clearStreamProxyAddEditForm(){ $('#streamProxyForm').find('input:not([type=checkbox]), select').val(''); - $('#streamProxyForm select').dropdown('clear'); + $('#streamProxyForm select[name=proxyProtocolVersion]').dropdown('set selected', '0'); $("#streamProxyForm input[name=timeout]").val(10); $("#streamProxyForm .toggle.checkbox").checkbox("set unchecked"); } @@ -212,8 +213,10 @@ modeText.push("UDP") } - if (config.UseProxyProtocol){ - modeText.push("ProxyProtocol V1") + if (config.ProxyProtocolVersion === 1) { + modeText.push("ProxyProtocol V1"); + } else if (config.ProxyProtocolVersion === 2) { + modeText.push("ProxyProtocol V2"); } modeText = modeText.join(" & ") @@ -277,13 +280,8 @@ $(checkboxEle).checkbox("set unchecked"); } return; - }else if (key == "UseProxyProtocol"){ - let checkboxEle = $("#streamProxyForm input[name=useProxyProtocol]").parent(); - if (value === true){ - $(checkboxEle).checkbox("set checked"); - }else{ - $(checkboxEle).checkbox("set unchecked"); - } + }else if (key == "ProxyProtocolVersion") { + $("#streamProxyForm select[name=proxyProtocolVersion]").dropdown("set selected", value); return; }else if (key == "EnableLogging"){ let checkboxEle = $("#streamProxyForm input[name=enableLogging]").parent(); @@ -342,7 +340,7 @@ proxyAddr: $("#streamProxyForm input[name=proxyAddr]").val().trim(), useTCP: $("#streamProxyForm input[name=useTCP]")[0].checked , useUDP: $("#streamProxyForm input[name=useUDP]")[0].checked , - useProxyProtocol: $("#streamProxyForm input[name=useProxyProtocol]")[0].checked , + proxyProtocolVersion: parseInt($("#streamProxyForm select[name=proxyProtocolVersion]").val(), 10), enableLogging: $("#streamProxyForm input[name=enableLogging]")[0].checked , timeout: parseInt($("#streamProxyForm input[name=timeout]").val().trim()), }, From aa243f23eebe198f87699e5c478302d36f162494 Mon Sep 17 00:00:00 2001 From: jemmy1794 Date: Tue, 14 Oct 2025 21:56:11 +0800 Subject: [PATCH 10/14] Add WriteProxyProtocolHeaderUDP function to support Proxy Protocol v2 for UDP connections --- src/mod/streamproxy/udpprox.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/mod/streamproxy/udpprox.go b/src/mod/streamproxy/udpprox.go index dc761df..672688c 100644 --- a/src/mod/streamproxy/udpprox.go +++ b/src/mod/streamproxy/udpprox.go @@ -1,11 +1,14 @@ package streamproxy import ( + "bytes" "errors" "log" "net" "strings" "time" + + proxyproto "github.com/pires/go-proxyproto" ) /* @@ -82,6 +85,24 @@ func (c *ProxyRelayInstance) CloseAllUDPConnections() { }) } +// Write Proxy Protocol v2 header to UDP connection +func WriteProxyProtocolHeaderUDP(conn *net.UDPConn, srcAddr, dstAddr *net.UDPAddr) error { + header := proxyproto.Header{ + Version: 2, + Command: proxyproto.PROXY, + TransportProtocol: proxyproto.UDPv4, + SourceAddr: srcAddr, + DestinationAddr: dstAddr, + } + var buf bytes.Buffer + _, err := header.WriteTo(&buf) + if err != nil { + return err + } + _, err = conn.Write(buf.Bytes()) + return err +} + func (c *ProxyRelayInstance) ForwardUDP(address1, address2 string, stopChan chan bool) error { //By default the incoming listen Address is int //We need to add the loopback address into it @@ -142,6 +163,10 @@ func (c *ProxyRelayInstance) ForwardUDP(address1, address2 string, stopChan chan // Fire up routine to manage new connection go c.RunUDPConnectionRelay(conn, lisener) + // Send Proxy Protocol header if enabled + if c.ProxyProtocolVersion == 2 { + _ = WriteProxyProtocolHeaderUDP(conn.ServerConn, cliaddr, targetAddr) + } } else { c.LogMsg("[UDP] Found connection for client "+saddr, nil) conn = rawConn.(*udpClientServerConn) From 824972a1e25969d63791c453a495678ce3c86a5f Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Wed, 15 Oct 2025 07:40:48 +0800 Subject: [PATCH 11/14] Added custom type for Proxy Protocol Version - Changed enum type for proxy protocol - Added warning for proxy protocol 1 on UDP selection in UI --- src/mod/streamproxy/handler.go | 2 +- src/mod/streamproxy/streamproxy.go | 61 +++++++++++++++++++++++------- src/mod/streamproxy/tcpprox.go | 6 +-- src/mod/streamproxy/udpprox.go | 4 +- src/web/components/streamprox.html | 18 +++++++++ 5 files changed, 71 insertions(+), 20 deletions(-) diff --git a/src/mod/streamproxy/handler.go b/src/mod/streamproxy/handler.go index 33faa66..72a593e 100644 --- a/src/mod/streamproxy/handler.go +++ b/src/mod/streamproxy/handler.go @@ -58,7 +58,7 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) { Timeout: timeout, UseTCP: useTCP, UseUDP: useUDP, - ProxyProtocolVersion: ProxyProtocolVersion, + ProxyProtocolVersion: convertIntToProxyProtocolVersion(ProxyProtocolVersion), EnableLogging: enableLogging, }) diff --git a/src/mod/streamproxy/streamproxy.go b/src/mod/streamproxy/streamproxy.go index 428ca54..599c71f 100644 --- a/src/mod/streamproxy/streamproxy.go +++ b/src/mod/streamproxy/streamproxy.go @@ -15,13 +15,22 @@ import ( ) /* - TCP Proxy + Stream Proxy Forward port from one port to another Also accept active connection and passive connection */ +// ProxyProtocolVersion enum type +type ProxyProtocolVersion int + +const ( + ProxyProtocolDisabled ProxyProtocolVersion = 0 + ProxyProtocolV1 ProxyProtocolVersion = 1 + ProxyProtocolV2 ProxyProtocolVersion = 2 +) + type ProxyRelayOptions struct { Name string ListeningAddr string @@ -29,7 +38,7 @@ type ProxyRelayOptions struct { Timeout int UseTCP bool UseUDP bool - ProxyProtocolVersion int + ProxyProtocolVersion ProxyProtocolVersion EnableLogging bool } @@ -48,17 +57,17 @@ type ProxyRuleUpdateConfig struct { type ProxyRelayInstance struct { /* Runtime Config */ - UUID string //A UUIDv4 representing this config - Name string //Name of the config - Running bool //Status, read only - AutoStart bool //If the service suppose to started automatically - ListeningAddress string //Listening Address, usually 127.0.0.1:port - ProxyTargetAddr string //Proxy target address - UseTCP bool //Enable TCP proxy - UseUDP bool //Enable UDP proxy - ProxyProtocolVersion int //Proxy Protocol v1/v2 - EnableLogging bool //Enable logging for ProxyInstance - Timeout int //Timeout for connection in sec + UUID string //A UUIDv4 representing this config + Name string //Name of the config + Running bool //Status, read only + AutoStart bool //If the service suppose to started automatically + ListeningAddress string //Listening Address, usually 127.0.0.1:port + ProxyTargetAddr string //Proxy target address + UseTCP bool //Enable TCP proxy + UseUDP bool //Enable UDP proxy + ProxyProtocolVersion ProxyProtocolVersion //Proxy Protocol v1/v2 + EnableLogging bool //Enable logging for ProxyInstance + Timeout int //Timeout for connection in sec /* Internal */ tcpStopChan chan bool //Stop channel for TCP listener @@ -203,6 +212,30 @@ func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayInstance, error return nil, errors.New("config not found") } +// ConvertIntToProxyProtocolVersion converts an int to ProxyProtocolVersion type +func convertIntToProxyProtocolVersion(v int) ProxyProtocolVersion { + switch v { + case 1: + return ProxyProtocolV1 + case 2: + return ProxyProtocolV2 + default: + return ProxyProtocolDisabled + } +} + +// convertProxyProtocolVersionToInt converts ProxyProtocolVersion type back to int +func convertProxyProtocolVersionToInt(v ProxyProtocolVersion) int { + switch v { + case ProxyProtocolV1: + return 1 + case ProxyProtocolV2: + return 2 + default: + return 0 + } +} + // Edit the config based on config UUID, leave empty for unchange fields func (m *Manager) EditConfig(newConfig *ProxyRuleUpdateConfig) error { // Find the config with the specified UUID @@ -224,7 +257,7 @@ func (m *Manager) EditConfig(newConfig *ProxyRuleUpdateConfig) error { foundConfig.UseTCP = newConfig.UseTCP foundConfig.UseUDP = newConfig.UseUDP - foundConfig.ProxyProtocolVersion = newConfig.ProxyProtocolVersion + foundConfig.ProxyProtocolVersion = convertIntToProxyProtocolVersion(newConfig.ProxyProtocolVersion) foundConfig.EnableLogging = newConfig.EnableLogging if newConfig.NewTimeout != -1 { diff --git a/src/mod/streamproxy/tcpprox.go b/src/mod/streamproxy/tcpprox.go index b102807..07807f0 100644 --- a/src/mod/streamproxy/tcpprox.go +++ b/src/mod/streamproxy/tcpprox.go @@ -46,7 +46,7 @@ func (c *ProxyRelayInstance) connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.W wg.Done() } -func WriteProxyProtocolHeader(dst net.Conn, src net.Conn, version int) error { +func WriteProxyProtocolHeader(dst net.Conn, src net.Conn, version ProxyProtocolVersion) error { clientAddr, ok1 := src.RemoteAddr().(*net.TCPAddr) proxyAddr, ok2 := src.LocalAddr().(*net.TCPAddr) if !ok1 || !ok2 { @@ -54,7 +54,7 @@ func WriteProxyProtocolHeader(dst net.Conn, src net.Conn, version int) error { } header := proxyproto.Header{ - Version: byte(version), + Version: byte(convertProxyProtocolVersionToInt(version)), Command: proxyproto.PROXY, TransportProtocol: proxyproto.TCPv4, SourceAddr: clientAddr, @@ -165,7 +165,7 @@ func (c *ProxyRelayInstance) Port2host(allowPort string, targetAddress string, s } c.LogMsg("[→] connect target address ["+targetAddress+"] success.", nil) - if c.ProxyProtocolVersion != 0 { + if c.ProxyProtocolVersion != ProxyProtocolDisabled { c.LogMsg("[+] write proxy protocol header to target address ["+targetAddress+"]", nil) err = WriteProxyProtocolHeader(target, conn, c.ProxyProtocolVersion) if err != nil { diff --git a/src/mod/streamproxy/udpprox.go b/src/mod/streamproxy/udpprox.go index 672688c..bf21158 100644 --- a/src/mod/streamproxy/udpprox.go +++ b/src/mod/streamproxy/udpprox.go @@ -88,7 +88,7 @@ func (c *ProxyRelayInstance) CloseAllUDPConnections() { // Write Proxy Protocol v2 header to UDP connection func WriteProxyProtocolHeaderUDP(conn *net.UDPConn, srcAddr, dstAddr *net.UDPAddr) error { header := proxyproto.Header{ - Version: 2, + Version: byte(ProxyProtocolV2), Command: proxyproto.PROXY, TransportProtocol: proxyproto.UDPv4, SourceAddr: srcAddr, @@ -164,7 +164,7 @@ func (c *ProxyRelayInstance) ForwardUDP(address1, address2 string, stopChan chan go c.RunUDPConnectionRelay(conn, lisener) // Send Proxy Protocol header if enabled - if c.ProxyProtocolVersion == 2 { + if c.ProxyProtocolVersion == ProxyProtocolV2 { _ = WriteProxyProtocolHeaderUDP(conn.ServerConn, cliaddr, targetAddr) } } else { diff --git a/src/web/components/streamprox.html b/src/web/components/streamprox.html index 3746a9b..16cf0eb 100644 --- a/src/web/components/streamprox.html +++ b/src/web/components/streamprox.html @@ -90,6 +90,9 @@ Select Proxy Protocol v1 / v2 to use (if any) +
@@ -100,6 +103,21 @@