27 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
Toby Chui
ca12facaf2 Fixed bug in sidebar plugin list update
- Fixed remove plugin when the plugin is still running but plugin in sidebar not automatically removed bug
2025-10-09 19:14:12 +08:00
Toby Chui
88aba38495 Merge pull request #837 from tobychui/v3.2.7
V3.2.7
2025-10-09 18:43:50 +08:00
PassiveLemon
95375d3298 Update logrotate flag description 2025-10-09 00:08:28 -04:00
Toby Chui
2c3f36d9a3 Merge pull request #829 from jimmyGALLAND/fix-acme-renew
fix acme renew
2025-10-03 07:16:20 +08:00
jimmyGALLAND
030ef2e01c allow domain labels with no minimum length 2025-10-02 12:42:13 +02:00
Toby Chui
85cad1e2b6 Updated #821 2025-09-26 07:12:17 +08:00
Toby Chui
94afb6e3a5 Optimized mobile side menu
- Optimized width for mobile side menu items
2025-09-25 21:11:28 +08:00
Toby Chui
84a4eaaf95 Fixed #821
- Added the recommended code snippet
2025-09-25 21:11:10 +08:00
Toby Chui
f98e1b8218 Fixed #706
- Added conditional injection of x-proxy-by zoraxy header by only injecting tracker when -dev flag is set to true
2025-09-24 21:53:11 +08:00
Toby Chui
778df1af0f Updated #411
- Added support for human readable units in -logrotate flag
2025-09-24 20:31:53 +08:00
jimmyGALLAND
2140e5b0b5 -Add support for including Subject Alternative Names (SANs) from
existing certificates during both manual and automatic renewals.
-Enhance filtering and normalization of domain names from the UI
to ensure only valid domains are included when requesting certificates.
2025-09-23 23:36:49 +02:00
Toby Chui
e9c1d14e23 Removed unused code from PR 2025-09-22 20:07:38 +08:00
Toby Chui
5477822015 Merge pull request #828 from jimmyGALLAND/fix-restart-after-acme-DNS-challenge
Fix restart after acme dns challenge
2025-09-22 20:05:20 +08:00
jimmyGALLAND
b0922c466d fix: restart issue after ACME certificate update with DNS challenge 2025-09-22 01:21:21 +02:00
Toby Chui
1faaae21d7 Merge pull request #827 from Saeraphinx/webui-style-changes
Update Sidebar CSS
2025-09-20 12:32:42 +08:00
Saera
53c73e1e77 update styling 2025-09-19 11:44:20 -05:00
Toby Chui
0805da9d13 Added more test cases for netutil ipmatch 2025-09-19 21:14:20 +08:00
Toby Chui
52f652fbaf Enable SNI offload in HTTPS proxy connections
Updated the ReverseProxy's ProxyHTTPS method to use tls.Dial with SNI support when connecting to upstream servers. Also incremented SYSTEM_VERSION to 3.2.7.
2025-09-17 07:37:21 +08:00
26 changed files with 583 additions and 89 deletions

View File

@@ -90,7 +90,7 @@ Variables are the same as those in [Start Parameters](https://github.com/tobychu
| `ENABLELOG` | `true` (Boolean) | Enable system wide logging, set to false for writing log to STDOUT only. |
| `ENABLELOGCOMPRESS` | `true` (Boolean) | Enable log compression for rotated log files. |
| `FASTGEOIP` | `false` (Boolean) | Enable high speed geoip lookup, require 1GB extra memory (Not recommend for low end devices). |
| `LOGROTATE` | `0` (Integer) | Enable log rotation and set the maximum log file size in KB (e.g. 25 for 25KB), set to 0 for disable. |
| `LOGROTATE` | `0` (String) | Enable log rotation and set the maximum log file size (Supports K, M, G suffixes). Set to 0 to disable. |
| `MDNS` | `true` (Boolean) | Enable mDNS scanner and transponder. |
| `MDNSNAME` | `''` (String) | mDNS name, leave empty to use default (zoraxy_{node-uuid}.local). |
| `NOAUTH` | `false` (Boolean) | Disable authentication for management interface. |

View File

@@ -44,7 +44,7 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.2.6"
SYSTEM_VERSION = "3.2.7"
DEVELOPMENT_BUILD = false
/* System Constants */
@@ -97,7 +97,7 @@ var (
/* Logging Configuration Flags */
enableLog = flag.Bool("enablelog", true, "Enable system wide logging, set to false for writing log to STDOUT only")
enableLogCompression = flag.Bool("enablelogcompress", true, "Enable log compression for rotated log files")
logRotate = flag.Int("logrotate", 0, "Enable log rotation and set the maximum log file size in KB (e.g. 25 for 25KB), set to 0 for disable")
logRotate = flag.String("logrotate", "0", "Enable log rotation and set the maximum log file size in KB, also support K, M, G suffix (e.g. 200M), set to 0 to disable")
/* Default Configuration Flags */
defaultInboundPort = flag.Int("default_inbound_port", 443, "Default web server listening port")

View File

@@ -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"
)
@@ -432,6 +433,21 @@ 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 != "" {
for _, d := range strings.Split(domainPara, ",") {
// Apply normalization on each domain
nd, err := netutils.NormalizeDomain(d)
if err != nil {
utils.SendErrorResponse(w, jsonEscape(err.Error()))
return
}
cleanedDomains = append(cleanedDomains, nd)
}
}
if err != nil {
utils.SendErrorResponse(w, jsonEscape(err.Error()))
return
@@ -492,8 +508,6 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
dns = true
}
domains := strings.Split(domainPara, ",")
// Default propagation timeout is 300 seconds
propagationTimeout := 300
if dns {
@@ -511,10 +525,28 @@ func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Requ
}
}
//Clean spaces in front or behind each domain
cleanedDomains := []string{}
for _, domain := range domains {
cleanedDomains = append(cleanedDomains, strings.TrimSpace(domain))
// Extract SANs from existing PEM to ensure all domains are included
pemPath := fmt.Sprintf("./conf/certs/%s.pem", filename)
sanDomains, err := ExtractDomainsFromPEM(pemPath)
if err == nil {
// Merge domainPara + SANs
domainSet := map[string]struct{}{}
for _, d := range cleanedDomains {
domainSet[d] = struct{}{}
}
for _, d := range sanDomains {
domainSet[d] = struct{}{}
}
// Rebuild cleanedDomains with all unique domains
cleanedDomains = []string{}
for d := range domainSet {
cleanedDomains = append(cleanedDomains, d)
}
a.Logf("Renewal domains including SANs from PEM: "+strings.Join(cleanedDomains, ","), nil)
} else {
a.Logf("Could not extract SANs from PEM, using domainPara only", err)
}
// Extract DNS servers from the request

View File

@@ -397,6 +397,15 @@ func (a *AutoRenewer) renewExpiredDomains(certs []*ExpiredCerts) ([]string, erro
dnsServers = strings.Join(certInfo.DNSServers, ",")
}
// Extract SANs from the existing PEM to ensure all domains are included
sanDomains, errSan := ExtractDomainsFromPEM(expiredCert.Filepath)
if errSan == nil && len(sanDomains) > 0 {
expiredCert.Domains = sanDomains
a.Logf("Using SANs from PEM for renewal: "+strings.Join(sanDomains, ","), nil)
} else {
a.Logf("Could not extract SANs from PEM for "+fileName+", using original domains", errSan)
}
_, err = a.AcmeHandler.ObtainCert(expiredCert.Domains, certName, a.RenewerConfig.Email, certInfo.AcmeName, certInfo.AcmeUrl, certInfo.SkipTLS, certInfo.UseDNS, certInfo.PropTimeout, dnsServers)
if err != nil {
a.Logf("Renew "+fileName+"("+strings.Join(expiredCert.Domains, ",")+") failed", err)

View File

@@ -64,6 +64,20 @@ func ExtractIssuerName(certBytes []byte) (string, error) {
return issuer, nil
}
// ExtractDomainsFromPEM reads a PEM certificate file and returns all SANs
func ExtractDomainsFromPEM(pemFilePath string) ([]string, error) {
certBytes, err := os.ReadFile(pemFilePath)
if err != nil {
return nil, err
}
domains, err := ExtractDomains(certBytes)
if err != nil {
return nil, err
}
return domains, nil
}
// Check if a cert is expired by public key
func CertIsExpired(certBytes []byte) bool {
block, _ := pem.Decode(certBytes)

View File

@@ -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

View File

@@ -2,6 +2,7 @@ package dpcore
import (
"context"
"crypto/tls"
"errors"
"io"
"log"
@@ -75,7 +76,8 @@ type ResponseRewriteRuleSet struct {
DisableChunkedTransferEncoding bool //Disable chunked transfer encoding
/* System Information Payload */
Version string //Version number of Zoraxy, use for X-Proxy-By
DevelopmentMode bool //Inject dev mode information to requests
Version string //Version number of Zoraxy, use for X-Proxy-By
}
type requestCanceler interface {
@@ -284,7 +286,7 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
// Add user defined headers (to upstream)
injectUserDefinedHeaders(outreq.Header, rrr.UpstreamHeaders)
// Rewrite outbound UA, must be after user headers
// Rewrite outbound UA top upstream, must be after user headers
rewriteUserAgent(outreq.Header, "Zoraxy/"+rrr.Version)
//Fix proxmox transfer encoding bug if detected Proxmox Cookie
@@ -292,6 +294,29 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
outreq.TransferEncoding = []string{"identity"}
}
//Fix for issue #821
if outreq.URL != nil && strings.EqualFold(outreq.URL.Scheme, "https") {
if tr, ok := transport.(*http.Transport); ok {
serverName := outreq.Host
if h, _, err := net.SplitHostPort(serverName); err == nil {
serverName = h
}
if tr.TLSClientConfig == nil || tr.TLSClientConfig.ServerName != serverName {
trc := tr.Clone()
var cfg *tls.Config
if tr.TLSClientConfig != nil {
cfg = tr.TLSClientConfig.Clone()
} else {
cfg = &tls.Config{}
}
cfg.ServerName = serverName
trc.TLSClientConfig = cfg
transport = trc
}
}
}
res, err := transport.RoundTrip(outreq)
if err != nil {
if p.Verbal {
@@ -323,7 +348,9 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
}
//Add debug X-Proxy-By tracker
res.Header.Set("x-proxy-by", "zoraxy/"+rrr.Version)
if rrr.DevelopmentMode {
res.Header.Set("x-proxy-by", "zoraxy/"+rrr.Version)
}
//Custom Location header rewriter functions
if res.Header.Get("Location") != "" {
@@ -391,7 +418,6 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
return res.StatusCode, nil
}
func (p *ReverseProxy) ProxyHTTPS(rw http.ResponseWriter, req *http.Request) (int, error) {
hij, ok := rw.(http.Hijacker)
if !ok {
@@ -407,12 +433,31 @@ func (p *ReverseProxy) ProxyHTTPS(rw http.ResponseWriter, req *http.Request) (in
return http.StatusInternalServerError, err
}
proxyConn, err := net.Dial("tcp", req.URL.Host)
// Extract SNI/hostname for TLS handshake
host := req.URL.Host
if !strings.Contains(host, ":") {
host += ":443"
}
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{
ServerName: serverName,
}
proxyConn, err := tls.Dial("tcp", host, tlsConfig)
if err != nil {
if p.Verbal {
p.logf("http: proxy error: %v", err)
}
clientConn.Close()
return http.StatusInternalServerError, err
}

View File

@@ -81,6 +81,7 @@ func (router *Router) StartProxyService() error {
if router.Option.ForceTLSLatest {
minVersion = tls.VersionTLS12
}
config := &tls.Config{
GetCertificate: router.Option.TlsManager.GetCert,
MinVersion: uint16(minVersion),
@@ -171,6 +172,7 @@ func (router *Router) StartProxyService() error {
NoRemoveHopByHop: endpointProxyRewriteRules.DisableHopByHopHeaderRemoval,
PathPrefix: "",
Version: sep.parent.Option.HostVersion,
DevelopmentMode: sep.parent.Option.DevelopmentMode,
})
return
}
@@ -265,63 +267,77 @@ func (router *Router) StartProxyService() error {
return nil
}
// StopProxyService stops the proxy server and waits for all listeners to close
func (router *Router) StopProxyService() error {
if router.server == nil {
if router.server == nil && router.tlsListener == nil && router.tlsRedirectStop == nil {
return errors.New("reverse proxy server already stopped")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := router.server.Shutdown(ctx)
if err != nil {
return err
var wg sync.WaitGroup
// Stop main TLS/HTTP server
if router.server != nil {
wg.Add(1)
go func(srv *http.Server) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := srv.Shutdown(ctx)
if err != nil {
router.Option.Logger.PrintAndLog("dprouter", "Error shutting down main server", err)
}
}(router.server)
}
//Stop TLS listener
if router.tlsListener != nil {
router.tlsListener.Close()
}
//Stop rate limiter
if router.rateLimterStop != nil {
go func() {
// As the rate timer loop has a 1 sec ticker
// stop the rate limiter in go routine can prevent
// front end from freezing for 1 sec
router.rateLimterStop <- true
}()
}
//Stop TLS redirection (from port 80)
// Stop TLS redirect server
if router.tlsRedirectStop != nil {
router.tlsRedirectStop <- true
wg.Add(1)
go func(ch chan bool) {
defer wg.Done()
ch <- true
}(router.tlsRedirectStop)
}
//Discard the server object
router.tlsListener = nil
// Stop rate limiter ticker if exists
if router.rateLimterStop != nil {
wg.Add(1)
go func(ch chan bool) {
defer wg.Done()
ch <- true
}(router.rateLimterStop)
}
// Wait for all shutdown goroutines to finish
wg.Wait()
// Clear server references
router.server = nil
router.Running = false
router.tlsListener = nil
router.tlsRedirectStop = nil
router.rateLimterStop = nil
router.Running = false
router.Option.Logger.PrintAndLog("dprouter", "Proxy service stopped successfully", nil)
return nil
}
// Restart the current router if it is running.
// Restart safely restarts the proxy server
func (router *Router) Restart() error {
//Stop the router if it is already running
if router.Running {
err := router.StopProxyService()
if err != nil {
return err
}
time.Sleep(100 * time.Millisecond)
// Start the server
err = router.StartProxyService()
if err != nil {
router.Option.Logger.PrintAndLog("dprouter", "Restarting proxy server...", nil)
if err := router.StopProxyService(); err != nil {
return err
}
// Ensure ports are released
time.Sleep(200 * time.Millisecond)
}
if err := router.StartProxyService(); err != nil {
router.Option.Logger.PrintAndLog("dprouter", "Failed to restart proxy server", err)
return err
}
router.Option.Logger.PrintAndLog("dprouter", "Proxy server restarted successfully", nil)
return nil
}

View File

@@ -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

View File

@@ -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,47 @@ 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 {
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
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 +146,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" {
@@ -196,6 +223,7 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe
HostHeaderOverwrite: headerRewriteOptions.RequestHostOverwrite,
NoRemoveHopByHop: headerRewriteOptions.DisableHopByHopHeaderRemoval,
Version: target.parent.Option.HostVersion,
DevelopmentMode: target.parent.Option.DevelopmentMode,
})
//validate the error
@@ -289,6 +317,7 @@ func (h *ProxyHandler) vdirRequest(w http.ResponseWriter, r *http.Request, targe
DisableChunkedTransferEncoding: target.parent.DisableChunkedTransferEncoding,
HostHeaderOverwrite: headerRewriteOptions.RequestHostOverwrite,
Version: target.parent.parent.Option.HostVersion,
DevelopmentMode: target.parent.parent.Option.DevelopmentMode,
})
var dnsError *net.DNSError

View File

@@ -69,7 +69,8 @@ type RouterOption struct {
OAuth2Router *oauth2.OAuth2Router //OAuth2Router router for OAuth2Router authentication
/* Utilities */
Logger *logger.Logger //Logger for reverse proxy requets
DevelopmentMode bool //Enable development mode, provide more debug information in headers
Logger *logger.Logger //Logger for reverse proxy requests
}
/* Router Object */

View File

@@ -0,0 +1,25 @@
package eventsystem // import "imuslab.com/zoraxy/mod/eventsystem"
VARIABLES
var (
// Publisher is the singleton instance of the event manager
Publisher *eventManager
)
FUNCTIONS
func InitEventSystem(logger *logger.Logger)
InitEventSystem initializes the event manager with the plugin manager
TYPES
type Listener interface {
Notify(event events.Event) error
GetID() ListenerID
}
type ListenerID string

View File

@@ -108,14 +108,24 @@ func MatchIpCIDR(ip string, cidr string) bool {
// Check if a ip is private IP range
func IsPrivateIP(ipStr string) bool {
if ipStr == "127.0.0.1" || ipStr == "::1" {
//local loopback
// local loopback
return true
}
ip := net.ParseIP(ipStr)
if ip == nil {
return false
}
return ip.IsPrivate()
if ip.IsPrivate() {
return true
}
// Check for IPv6 link-local addresses (fe80::/10)
if ip.To16() != nil && ip.To4() == nil {
// IPv6 only
if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 {
return true
}
}
return false
}
// Check if an Ip string is ipv6

View File

@@ -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
}

View File

@@ -26,3 +26,56 @@ func TestHandlePing(t *testing.T) {
t.Log(realIP, pingTime, ttl)
}
func TestMatchIpWildcard_IPv6(t *testing.T) {
// IPv6 wildcards are not supported by MatchIpWildcard, so these should all return false
tests := []struct {
ip string
wildcard string
want bool
}{
{"fd7a:115c:a1e0::e101:6f0f", "fd7a:115c:a1e0::e101:6f0f", false}, // not supported
{"fd7a:115c:a1e0::e101:6f0f", "*:*:*:*:*:*:*:*", false},
}
for _, tt := range tests {
got := netutils.MatchIpWildcard(tt.ip, tt.wildcard)
if got != tt.want {
t.Errorf("MatchIpWildcard(%q, %q) = %v, want %v", tt.ip, tt.wildcard, got, tt.want)
}
}
}
func TestMatchIpCIDR_IPv6(t *testing.T) {
tests := []struct {
ip string
cidr string
want bool
}{
{"fd7a:115c:a1e0::e101:6f0f", "fd7a:115c:a1e0::/48", true},
{"fd7a:115c:a1e0::e101:6f0f", "fd7a:115c:a1e0::/64", true},
{"fd7a:115c:a1e0::e101:6f0f", "fd7a:115c:a1e1::/48", false},
{"fd7a:115c:a1e0::e101:6f0f", "fd7a:115c:a1e0::/128", false},
}
for _, tt := range tests {
got := netutils.MatchIpCIDR(tt.ip, tt.cidr)
if got != tt.want {
t.Errorf("MatchIpCIDR(%q, %q) = %v, want %v", tt.ip, tt.cidr, got, tt.want)
}
}
}
func TestIsPrivateIP_IPv6(t *testing.T) {
tests := []struct {
ip string
want bool
}{
{"fd7a:115c:a1e0::e101:6f0f", true}, // Unique local address (fc00::/7)
{"fe80::1", true}, // Link-local
{"2001:db8::1", false}, // Documentation address
}
for _, tt := range tests {
got := netutils.IsPrivateIP(tt.ip)
if got != tt.want {
t.Errorf("IsPrivateIP(%q) = %v, want %v", tt.ip, got, tt.want)
}
}
}

View File

@@ -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 := ""

View File

@@ -22,6 +22,72 @@ func Int64ToString(number int64) string {
return convedNumber
}
func SizeStringToBytes(sizeStr string) (int64, error) {
sizeStr = strings.TrimSpace(sizeStr)
if len(sizeStr) == 0 {
return 0, nil
}
// Extract unit (1 or 2 characters) from the end of the string
var unit string
var sizeValue string
sizeStrLower := strings.ToLower(sizeStr)
if len(sizeStrLower) > 2 && (strings.HasSuffix(sizeStrLower, "kb") || strings.HasSuffix(sizeStrLower, "mb") || strings.HasSuffix(sizeStrLower, "gb") || strings.HasSuffix(sizeStrLower, "tb") || strings.HasSuffix(sizeStrLower, "pb")) {
unit = sizeStrLower[len(sizeStrLower)-2:]
sizeValue = sizeStrLower[:len(sizeStrLower)-2]
} else if len(sizeStrLower) > 1 && (strings.HasSuffix(sizeStrLower, "k") || strings.HasSuffix(sizeStrLower, "m") || strings.HasSuffix(sizeStrLower, "g") || strings.HasSuffix(sizeStrLower, "t") || strings.HasSuffix(sizeStrLower, "p")) {
unit = sizeStrLower[len(sizeStrLower)-1:]
sizeValue = sizeStrLower[:len(sizeStrLower)-1]
} else {
unit = ""
sizeValue = sizeStrLower
}
size, err := strconv.ParseFloat(sizeValue, 64)
if err != nil {
return 0, err
}
switch unit {
case "k", "kb":
size *= 1024
case "m", "mb":
size *= 1024 * 1024
case "g", "gb":
size *= 1024 * 1024 * 1024
case "t", "tb":
size *= 1024 * 1024 * 1024 * 1024
case "p", "pb":
size *= 1024 * 1024 * 1024 * 1024 * 1024
case "":
// No unit, size is already in bytes
default:
return 0, nil // Unknown unit
}
return int64(size), nil
}
func BytesToHumanReadable(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
TB = GB * 1024
)
switch {
case bytes >= TB:
return strconv.FormatFloat(float64(bytes)/float64(TB), 'f', 2, 64) + " TB"
case bytes >= GB:
return strconv.FormatFloat(float64(bytes)/float64(GB), 'f', 2, 64) + " GB"
case bytes >= MB:
return strconv.FormatFloat(float64(bytes)/float64(MB), 'f', 2, 64) + " MB"
case bytes >= KB:
return strconv.FormatFloat(float64(bytes)/float64(KB), 'f', 2, 64) + " KB"
default:
return strconv.FormatInt(bytes, 10) + " Bytes"
}
}
func ReplaceSpecialCharacters(filename string) string {
replacements := map[string]string{
"#": "%pound%",

View File

@@ -0,0 +1,41 @@
package utils_test
import (
"testing"
"imuslab.com/zoraxy/mod/utils"
"github.com/stretchr/testify/assert"
)
func TestSizeStringToBytes(t *testing.T) {
tests := []struct {
input string
expected int64
hasError bool
}{
{"1024", 1024, false},
{"1k", 1024, false},
{"1K", 1024, false},
{"2kb", 2 * 1024, false},
{"1m", 1024 * 1024, false},
{"3mb", 3 * 1024 * 1024, false},
{"1g", 1024 * 1024 * 1024, false},
{"2gb", 2 * 1024 * 1024 * 1024, false},
{"", 0, false},
{" 5mb ", 5 * 1024 * 1024, false},
{"invalid", 0, true},
{"1tb", 1099511627776, false}, // Unknown unit returns 0, nil
{"1.5mb", int64(1.5 * 1024 * 1024), false},
}
for _, tt := range tests {
got, err := utils.SizeStringToBytes(tt.input)
if tt.hasError {
assert.Error(t, err, "input: %s", tt.input)
} else {
assert.NoError(t, err, "input: %s", tt.input)
assert.Equal(t, tt.expected, got, "input: %s", tt.input)
}
}
}

View File

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

View File

@@ -122,7 +122,8 @@ func ReverseProxyInit() {
LoadBalancer: loadBalancer,
PluginManager: pluginManager,
/* Utilities */
Logger: SystemWideLogger,
DevelopmentMode: *development_build,
Logger: SystemWideLogger,
})
if err != nil {
SystemWideLogger.PrintAndLog("proxy-config", "Unable to create dynamic proxy router", err)
@@ -237,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"
@@ -409,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)

View File

@@ -12,6 +12,7 @@ import (
"imuslab.com/zoraxy/mod/auth/sso/oauth2"
"imuslab.com/zoraxy/mod/eventsystem"
"imuslab.com/zoraxy/mod/utils"
"github.com/gorilla/csrf"
"imuslab.com/zoraxy/mod/access"
@@ -76,14 +77,24 @@ func startupSequence() {
SystemWideLogger = l
SystemWideLogger.Println("System wide logging is disabled, all logs will be printed to STDOUT only")
} else {
logRotateSize, err := utils.SizeStringToBytes(*logRotate)
if err != nil {
//Default disable
logRotateSize = 0
}
l.SetRotateOption(&logger.RotateOption{
Enabled: *logRotate != 0,
MaxSize: int64(*logRotate) * 1024, //Convert to bytes
Enabled: logRotateSize != 0,
MaxSize: int64(logRotateSize),
MaxBackups: 10,
Compress: *enableLogCompression,
BackupDir: "",
})
SystemWideLogger = l
if logRotateSize == 0 {
SystemWideLogger.Println("Log rotation is disabled")
} else {
SystemWideLogger.Println("Log rotation is enabled, max log file size " + utils.BytesToHumanReadable(int64(logRotateSize)))
}
SystemWideLogger.Println("System wide logging is enabled")
}

View File

@@ -615,6 +615,7 @@ function uninstallPlugin(pluginId, pluginName, btn=undefined) {
} else {
msgbox(pluginName + " uninstalled successfully", true);
initiatePluginList();
initPluginSideMenu();
}
}
});

View File

@@ -62,6 +62,13 @@
<input type="checkbox" id="useStickySessionLB">
<label>Sticky Session<br><small>Enable stick session on upstream load balancing</small></label>
</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 class="field">
<label>Tags</label>
@@ -168,22 +175,78 @@
</div>
<div class="six wide column">
<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>
Example of domain matching keyword:<br>
<code>aroz.org</code> <br>Any acess requesting aroz.org will be proxy to the IP address below<br>
<div class="ui divider"></div>
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Subdomain</span><br>
Example of subdomain matching keyword:<br>
<code>s1.aroz.org</code> <br>Any request starting with s1.aroz.org will be proxy to the IP address below<br>
<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 class="ui fluid styled accordion" id="matchingKeywordExamplesAccordion" style="background-color: transparent !important;">
<div class="title active" style="color: white;">
<i class="dropdown icon"></i>
Matching Keyword Examples
</div>
<div class="content active">
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Domain</span><br>
Example of domain matching keyword:<br>
<code>aroz.org</code> <br>Any acess requesting aroz.org will be proxy to the IP address below<br>
<div class="ui divider"></div>
<span style="font-size: 1.2em; font-weight: 300;"><i class="ui yellow star icon"></i> Subdomain</span><br>
Example of subdomain matching keyword:<br>
<code>s1.aroz.org</code> <br>Any request starting with s1.aroz.org will be proxy to the IP address below<br>
<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>
<br>
<script>
$('#matchingKeywordExamplesAccordion').accordion();
</script>
</div>
</div>
</div>
@@ -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){

View File

@@ -336,7 +336,7 @@ body.darkTheme .ui.segment.advanceoptions {
body.darkTheme .ui.segment{
background-color: transparent !important;
color: var(--text_color) !important;
border: 1px solid transparent !important;
border: 0 /*solid transparent*/ !important;
}
body.darkTheme .sub.header {

View File

@@ -173,7 +173,7 @@
</div>
<br><br>
<div class="ui divider"></div>
<div class="ui container" style="color: grey; font-size: 90%">
<div class="ui container" style="color: grey; font-size: 90%; padding-bottom: 1em;">
<p><a href="https://zoraxy.aroz.org" target="_blank">Zoraxy</a> <span class="zrversion"></span> © 2021 - <span class="year"></span> tobychui. Licensed under AGPL</p>
</div>

View File

@@ -72,15 +72,17 @@ body.darkTheme .menubar{
width: 240px;
position: sticky;
top: 4em;
padding: 0 1em;
}
.contentWindow{
display: inline-block;
width: calc(100% - 240px);
width: calc(100% - 244px);
vertical-align: top;
background-color: var(--theme_bg_primary);
border-radius: 1em;
margin-right: 2em;
margin-right: 1em;
flex-shrink: 0;
}
.menutoggle{
@@ -278,7 +280,7 @@ body.darkTheme .menubar{
}
#mainmenu{
width: calc(100% - 1em);
width: calc(100% - 0.1em);
}
.contentWindow{
@@ -309,8 +311,17 @@ body.darkTheme .menubar{
font-size: 0.8em !important;
color: #9c9c9c !important;
padding-left: 0.6em;
/* fix the divider being really weird in the sidebar menu */
margin: 1rem 0 0.25rem 0 !important;
height: 2em !important;
border-bottom: 1px solid rgba(34,36,38,.15) !important;
border-top: 1px solid rgba(255,255,255,.1) !important;
}
body.darkTheme .menudivider{
border-bottom: 1px solid rgba(255,255,255,.15) !important;
border-top: 1px solid rgba(34,36,38,.1) !important;
}
/*
Global rules overwrite
*/