9 Commits

Author SHA1 Message Date
Toby Chui
9ed9d9ede4 Merge pull request #847 from zen8841/fix845
Move function:NormalizeDomain to netutils module
2025-10-13 18:35:40 +08:00
Zen Wen
ed1b0ec673 Move function:NormalizeDomain to netutils module 2025-10-13 13:16:20 +08:00
Toby Chui
b22e011131 Merge pull request #846 from zen8841/fix845
Fix #845
2025-10-13 12:48:07 +08:00
Zen Wen
d155ea3795 Linting and formatting 2025-10-13 00:50:28 +08:00
Zen Wen
dd610e5f75 fix #845 2025-10-13 00:23:15 +08:00
Zen Wen
c424b92036 Move function:NormalizeDomain to utils module 2025-10-13 00:16:31 +08:00
Toby Chui
2c270640e9 Added #843
- Added checkbox for disabling uptime monitor in new proxy
2025-10-11 21:36:16 +08:00
Toby Chui
cf2cf18136 Added check for loopback proxy enable state
- Added check and show 521 if the loopback proxy endpoint is disabled
2025-10-10 15:51:00 +08:00
Toby Chui
e77f947d1d Added loopback proxy support
- Added support for shortcut loopback setup in local setups
2025-10-10 14:43:38 +08:00
11 changed files with 198 additions and 84 deletions

View File

@@ -27,6 +27,7 @@ import (
"github.com/go-acme/lego/v4/registration" "github.com/go-acme/lego/v4/registration"
"imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/info/logger" "imuslab.com/zoraxy/mod/info/logger"
"imuslab.com/zoraxy/mod/netutils"
"imuslab.com/zoraxy/mod/utils" "imuslab.com/zoraxy/mod/utils"
) )
@@ -432,18 +433,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. // 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) { func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Request) {
domainPara, err := utils.PostPara(r, "domains") domainPara, err := utils.PostPara(r, "domains")
//Clean each domain //Clean each domain
cleanedDomains := []string{} cleanedDomains := []string{}
if (domainPara != "") { if domainPara != "" {
for _, d := range strings.Split(domainPara, ",") { for _, d := range strings.Split(domainPara, ",") {
// Apply normalization on each domain // Apply normalization on each domain
nd, err := NormalizeDomain(d) nd, err := netutils.NormalizeDomain(d)
if err != nil { if err != nil {
utils.SendErrorResponse(w, jsonEscape(err.Error())) utils.SendErrorResponse(w, jsonEscape(err.Error()))
return return
} }
cleanedDomains = append(cleanedDomains, nd) cleanedDomains = append(cleanedDomains, nd)
} }
} }
@@ -507,7 +508,6 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
dns = true dns = true
} }
// Default propagation timeout is 300 seconds // Default propagation timeout is 300 seconds
propagationTimeout := 300 propagationTimeout := 300
if dns { if dns {
@@ -549,7 +549,6 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
a.Logf("Could not extract SANs from PEM, using domainPara only", err) a.Logf("Could not extract SANs from PEM, using domainPara only", err)
} }
// Extract DNS servers from the request // Extract DNS servers from the request
var dnsServers []string var dnsServers []string
dnsServersPara, err := utils.PostPara(r, "dnsServers") dnsServersPara, err := utils.PostPara(r, "dnsServers")

View File

@@ -7,8 +7,6 @@ import (
"fmt" "fmt"
"os" "os"
"time" "time"
"strings"
"unicode"
) )
// Get the issuer name from pem file // Get the issuer name from pem file
@@ -42,8 +40,6 @@ func ExtractDomains(certBytes []byte) ([]string, error) {
return []string{}, errors.New("decode cert bytes failed") return []string{}, errors.New("decode cert bytes failed")
} }
func ExtractIssuerName(certBytes []byte) (string, error) { func ExtractIssuerName(certBytes []byte) (string, error) {
// Parse the PEM block // Parse the PEM block
block, _ := pem.Decode(certBytes) block, _ := pem.Decode(certBytes)
@@ -73,9 +69,9 @@ func ExtractDomainsFromPEM(pemFilePath string) ([]string, error) {
certBytes, err := os.ReadFile(pemFilePath) certBytes, err := os.ReadFile(pemFilePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
domains,err := ExtractDomains(certBytes) domains, err := ExtractDomains(certBytes)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -116,48 +112,3 @@ func CertExpireSoon(certBytes []byte, numberOfDays int) bool {
} }
return false 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
}

View File

@@ -92,7 +92,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
//Plugin routing //Plugin routing
if h.Parent.Option.PluginManager != nil && h.Parent.Option.PluginManager.HandleRoute(w, r, sep.Tags) { if h.Parent.Option.PluginManager != nil && h.Parent.Option.PluginManager.HandleRoute(w, r, sep.Tags) {
//Request handled by subroute //Request handled by subroute
return return

View File

@@ -438,7 +438,15 @@ func (p *ReverseProxy) ProxyHTTPS(rw http.ResponseWriter, req *http.Request) (in
if !strings.Contains(host, ":") { if !strings.Contains(host, ":") {
host += ":443" 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 // Connect with SNI offload
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{

View File

@@ -272,6 +272,11 @@ func (ep *ProxyEndpoint) Remove() error {
return nil 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 // Write changes to runtime without respawning the proxy handler
// use prepare -> remove -> add if you change anything in the endpoint // use prepare -> remove -> add if you change anything in the endpoint
// that effects the proxy routing src / dest // that effects the proxy routing src / dest

View File

@@ -12,6 +12,7 @@ import (
"strings" "strings"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
"imuslab.com/zoraxy/mod/dynamicproxy/loadbalance"
"imuslab.com/zoraxy/mod/dynamicproxy/rewrite" "imuslab.com/zoraxy/mod/dynamicproxy/rewrite"
"imuslab.com/zoraxy/mod/netutils" "imuslab.com/zoraxy/mod/netutils"
"imuslab.com/zoraxy/mod/statistic" "imuslab.com/zoraxy/mod/statistic"
@@ -95,27 +96,47 @@ func (router *Router) GetProxyEndpointFromHostname(hostname string) *ProxyEndpoi
return targetSubdomainEndpoint 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 / // Rewrite URL rewrite the prefix part of a virtual directory URL with /
func (router *Router) rewriteURL(rooturl string, requestURL string) string { func (router *Router) rewriteURL(rooturl string, requestURL string) string {
rewrittenURL := requestURL rewrittenURL := requestURL
rewrittenURL = strings.TrimPrefix(rewrittenURL, strings.TrimSuffix(rooturl, "/")) rewrittenURL = strings.TrimPrefix(rewrittenURL, strings.TrimSuffix(rooturl, "/"))
if strings.Contains(rewrittenURL, "//") { if strings.Contains(rewrittenURL, "//") {
rewrittenURL = router.clearnURL(rewrittenURL) rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
} }
return 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 {
upstreamHostname := selectedUpstream.OriginIpOrDomain
if strings.Contains(upstreamHostname, ":") {
upstreamHostname = strings.Split(upstreamHostname, ":")[0]
}
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)
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
}
// Handle host request // Handle host request
func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, target *ProxyEndpoint) { 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-Host", r.Host)
r.Header.Set("X-Forwarded-Server", "zoraxy-"+h.Parent.Option.HostUUID) r.Header.Set("X-Forwarded-Server", "zoraxy-"+h.Parent.Option.HostUUID)
reqHostname := r.Host reqHostname := r.Host
/* Load balancing */ /* Load balancing */
selectedUpstream, err := h.Parent.loadBalancer.GetRequestUpstreamTarget(w, r, target.ActiveOrigins, target.UseStickySession) selectedUpstream, err := h.Parent.loadBalancer.GetRequestUpstreamTarget(w, r, target.ActiveOrigins, target.UseStickySession)
if err != nil { if err != nil {
@@ -125,6 +146,12 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe
return 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 */ /* WebSocket automatic proxy */
requestURL := r.URL.String() requestURL := r.URL.String()
if r.Header["Upgrade"] != nil && strings.ToLower(r.Header["Upgrade"][0]) == "websocket" { if r.Header["Upgrade"] != nil && strings.ToLower(r.Header["Upgrade"][0]) == "websocket" {

View File

@@ -2,10 +2,13 @@ package netutils
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"unicode"
"github.com/likexian/whois" "github.com/likexian/whois"
"imuslab.com/zoraxy/mod/utils" "imuslab.com/zoraxy/mod/utils"
@@ -167,3 +170,53 @@ func CheckIfPortOccupied(portNumber int) bool {
listener.Close() listener.Close()
return false 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
}

View File

@@ -211,7 +211,6 @@ func getWebsiteStatus(url string) (int, error) {
} }
resp, err := client.Do(req) resp, err := client.Do(req)
//resp, err := client.Get(url)
if err != nil { if err != nil {
//Try replace the http with https and vise versa //Try replace the http with https and vise versa
rewriteURL := "" rewriteURL := ""

View File

@@ -199,4 +199,4 @@ func ValidateListeningAddress(address string) bool {
} }
return true return true
} }

View File

@@ -238,6 +238,13 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
bypassGlobalTLS, _ := utils.PostPara(r, "bypassGlobalTLS") bypassGlobalTLS, _ := utils.PostPara(r, "bypassGlobalTLS")
if bypassGlobalTLS == "" { if bypassGlobalTLS == "" {
bypassGlobalTLS = "false" bypassGlobalTLS = "false"
}
// Enable uptime monitor?
enableUtm, err := utils.PostBool(r, "enableUtm")
if err != nil {
enableUtm = true
} }
useBypassGlobalTLS := bypassGlobalTLS == "true" useBypassGlobalTLS := bypassGlobalTLS == "true"
@@ -410,7 +417,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
RequireRateLimit: requireRateLimit, RequireRateLimit: requireRateLimit,
RateLimit: int64(proxyRateLimit), RateLimit: int64(proxyRateLimit),
Tags: tags, Tags: tags,
DisableUptimeMonitor: !enableUtm,
} }
preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint) preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint)

View File

@@ -62,6 +62,13 @@
<input type="checkbox" id="useStickySessionLB"> <input type="checkbox" id="useStickySessionLB">
<label>Sticky Session<br><small>Enable stick session on upstream load balancing</small></label> <label>Sticky Session<br><small>Enable stick session on upstream load balancing</small></label>
</div> </div>
</div>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" id="enableUtm" checked>
<label>Enable uptime monitor<br><small>Automatically check upstream status and switch to another if offline</small>
</label>
</div>
</div> </div>
<div class="field"> <div class="field">
<label>Tags</label> <label>Tags</label>
@@ -168,22 +175,78 @@
</div> </div>
<div class="six wide column"> <div class="six wide column">
<div class="ui basic segment rulesInstructions"> <div class="ui basic segment rulesInstructions">
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Domain</span><br> <div class="ui fluid styled accordion" id="matchingKeywordExamplesAccordion" style="background-color: transparent !important;">
Example of domain matching keyword:<br> <div class="title active" style="color: white;">
<code>aroz.org</code> <br>Any acess requesting aroz.org will be proxy to the IP address below<br> <i class="dropdown icon"></i>
<div class="ui divider"></div> Matching Keyword Examples
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Subdomain</span><br> </div>
Example of subdomain matching keyword:<br> <div class="content active">
<code>s1.aroz.org</code> <br>Any request starting with s1.aroz.org will be proxy to the IP address below<br> <span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Domain</span><br>
<div class="ui divider"></div> Example of domain matching keyword:<br>
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Wildcard</span><br> <code>aroz.org</code> <br>Any acess requesting aroz.org will be proxy to the IP address below<br>
Example of wildcard matching keyword:<br> <div class="ui divider"></div>
<code>*.aroz.org</code> <br>Any request with a host name matching *.aroz.org will be proxy to the IP address below. Here are some examples.<br> <span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Subdomain</span><br>
<div class="ui list"> Example of subdomain matching keyword:<br>
<div class="item"><code>www.aroz.org</code></div> <code>s1.aroz.org</code> <br>Any request starting with s1.aroz.org will be proxy to the IP address below<br>
<div class="item"><code>foo.bar.aroz.org</code></div> <div class="ui divider"></div>
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Wildcard</span><br>
Example of wildcard matching keyword:<br>
<code>*.aroz.org</code> <br>Any request with a host name matching *.aroz.org will be proxy to the IP address below. Here are some examples.<br>
<div class="ui list">
<div class="item"><code>www.aroz.org</code></div>
<div class="item"><code>foo.bar.aroz.org</code></div>
</div>
<br>
</div>
<div class="title" style="color: white;">
<i class="dropdown icon"></i>
Remote Target Require TLS
</div>
<div class="content">
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui green lock icon"></i> Upstream TLS Requirement</span><br>
<p>
When you enable <b>Proxy Target require TLS Connection</b>, it means the <b>upstream server</b> (the target you are proxying to) requires a secure (HTTPS) connection.<br>
<b>This does not affect whether clients connect to this proxy endpoint using HTTP or HTTPS.</b>
</p>
<div class="ui divider"></div>
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Example</span><br>
<code>Matching Keyword: mydomain.com<br>
Target: example.com:443 (TLS enabled)</code><br>
<ul>
<li>Client connects to <b>mydomain.com</b> (HTTP or HTTPS, depending on your proxy setup)</li>
<li>Proxy forwards requests to <b>example.com:443</b> using <b>HTTPS</b></li>
</ul>
<small>
Use this option if your upstream server only accepts secure connections.<br>
If your upstream uses a self-signed certificate, check the <b>Ignore TLS/SSL Verification Error</b> option in Advance Settings.
</small>
</div>
<div class="title" style="color: white;">
<i class="dropdown icon"></i>
What is Sticky Session?
</div>
<div class="content">
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui green sync icon"></i> Sticky Session (Session Affinity)</span><br>
<p>
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.<br>
</p>
<div class="ui divider"></div>
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> How to Add Multiple Upstreams</span><br>
<ul>
<li>Go to <b>HTTP Proxy</b> in the sidebar.</li>
<li>Click <b>Edit</b> on your proxy rule.</li>
<li>Use the <b>Upstreams</b> section to add more upstream endpoints for load balancing.</li>
</ul>
<small>
Sticky session will only work if you have more than one upstream endpoint configured.
</small>
</div>
</div> </div>
<br> <script>
$('#matchingKeywordExamplesAccordion').accordion();
</script>
</div> </div>
</div> </div>
</div> </div>
@@ -204,6 +267,7 @@
let accessRuleToUse = $("#newProxyRuleAccessFilter").val(); let accessRuleToUse = $("#newProxyRuleAccessFilter").val();
let useStickySessionLB = $("#useStickySessionLB")[0].checked; let useStickySessionLB = $("#useStickySessionLB")[0].checked;
let tags = $("#proxyTags").val().trim(); let tags = $("#proxyTags").val().trim();
let enableUtm = $("#enableUtm")[0].checked;
if (rootname.trim() == ""){ if (rootname.trim() == ""){
$("#rootname").parent().addClass("error"); $("#rootname").parent().addClass("error");
@@ -238,6 +302,7 @@
access: accessRuleToUse, access: accessRuleToUse,
stickysess: useStickySessionLB, stickysess: useStickySessionLB,
tags: tags, tags: tags,
enableUtm: enableUtm,
}, },
success: function(data){ success: function(data){
if (data.error != undefined){ if (data.error != undefined){