diff --git a/.gitignore b/.gitignore index 26006a7..6e386d5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ docker/ImagePublisher.sh src/mod/acme/test/stackoverflow.pem /tools/dns_challenge_update/code-gen/acmedns /tools/dns_challenge_update/code-gen/lego +src/tmp/localhost.key +src/tmp/localhost.pem +src/www/html/index.html +src/sys.uuid +src/zoraxy +src/log/ \ No newline at end of file diff --git a/src/main.go b/src/main.go index d813665..44cf82b 100644 --- a/src/main.go +++ b/src/main.go @@ -52,9 +52,9 @@ var logOutputToFile = flag.Bool("log", true, "Log terminal output to file") var ( name = "Zoraxy" - version = "3.0.6" + version = "3.0.7" nodeUUID = "generic" - development = false //Set this to false to use embedded web fs + development = true //Set this to false to use embedded web fs bootTime = time.Now().Unix() /* diff --git a/src/mod/dynamicproxy/Server.go b/src/mod/dynamicproxy/Server.go index 2771059..62a0e89 100644 --- a/src/mod/dynamicproxy/Server.go +++ b/src/mod/dynamicproxy/Server.go @@ -72,6 +72,14 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Rate Limit + if sep.RequireRateLimit { + err := h.handleRateLimitRouting(w, r, sep) + if err != nil { + return + } + } + //Validate basic auth if sep.RequireBasicAuth { err := h.handleBasicAuthRouting(w, r, sep) diff --git a/src/mod/dynamicproxy/dpcore/header.go b/src/mod/dynamicproxy/dpcore/header.go index 40c029e..4c38ada 100644 --- a/src/mod/dynamicproxy/dpcore/header.go +++ b/src/mod/dynamicproxy/dpcore/header.go @@ -91,7 +91,6 @@ func addXForwardedForHeader(req *http.Request) { req.Header.Set("X-Real-Ip", strings.TrimSpace(ips[0])) } } - } } diff --git a/src/mod/dynamicproxy/dynamicproxy.go b/src/mod/dynamicproxy/dynamicproxy.go index 7532d09..b0e9b5e 100644 --- a/src/mod/dynamicproxy/dynamicproxy.go +++ b/src/mod/dynamicproxy/dynamicproxy.go @@ -23,12 +23,12 @@ import ( func NewDynamicProxy(option RouterOption) (*Router, error) { proxyMap := sync.Map{} thisRouter := Router{ - Option: &option, - ProxyEndpoints: &proxyMap, - Running: false, - server: nil, - routingRules: []*RoutingRule{}, - tldMap: map[string]int{}, + Option: &option, + ProxyEndpoints: &proxyMap, + Running: false, + server: nil, + routingRules: []*RoutingRule{}, + rateLimitCounter: RequestCountPerIpTable{}, } thisRouter.mux = &ProxyHandler{ @@ -85,6 +85,12 @@ func (router *Router) StartProxyService() error { MinVersion: uint16(minVersion), } + //Start rate limitor + err := router.startRateLimterCounterResetTicker() + if err != nil { + return err + } + if router.Option.UseTls { router.server = &http.Server{ Addr: ":" + strconv.Itoa(router.Option.Port), @@ -129,6 +135,13 @@ func (router *Router) StartProxyService() error { } } + // Rate Limit + if sep.RequireRateLimit { + if err := router.handleRateLimit(w, r, sep); err != nil { + return + } + } + //Validate basic auth if sep.RequireBasicAuth { err := handleBasicAuth(w, r, sep) @@ -232,10 +245,23 @@ func (router *Router) StopProxyService() error { return err } + //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) if router.tlsRedirectStop != nil { router.tlsRedirectStop <- true } diff --git a/src/mod/dynamicproxy/ratelimit.go b/src/mod/dynamicproxy/ratelimit.go new file mode 100644 index 0000000..17969e7 --- /dev/null +++ b/src/mod/dynamicproxy/ratelimit.go @@ -0,0 +1,119 @@ +package dynamicproxy + +import ( + "errors" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +// IpTable is a rate limiter implementation using sync.Map with atomic int64 +type RequestCountPerIpTable struct { + table sync.Map +} + +// Increment the count of requests for a given IP +func (t *RequestCountPerIpTable) Increment(ip string) { + v, _ := t.table.LoadOrStore(ip, new(int64)) + atomic.AddInt64(v.(*int64), 1) +} + +// Check if the IP is in the table and if it is, check if the count is less than the limit +func (t *RequestCountPerIpTable) Exceeded(ip string, limit int64) bool { + v, ok := t.table.Load(ip) + if !ok { + return false + } + count := atomic.LoadInt64(v.(*int64)) + return count >= limit +} + +// Get the count of requests for a given IP +func (t *RequestCountPerIpTable) GetCount(ip string) int64 { + v, ok := t.table.Load(ip) + if !ok { + return 0 + } + return atomic.LoadInt64(v.(*int64)) +} + +// Clear the IP table +func (t *RequestCountPerIpTable) Clear() { + t.table.Range(func(key, value interface{}) bool { + t.table.Delete(key) + return true + }) +} + +func (h *ProxyHandler) handleRateLimitRouting(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) error { + err := h.Parent.handleRateLimit(w, r, pe) + if err != nil { + h.logRequest(r, false, 429, "ratelimit", pe.Domain) + } + return err +} + +func (router *Router) handleRateLimit(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) error { + //Get the real client-ip from request header + clientIP := r.RemoteAddr + if r.Header.Get("X-Real-Ip") == "" { + CF_Connecting_IP := r.Header.Get("CF-Connecting-IP") + Fastly_Client_IP := r.Header.Get("Fastly-Client-IP") + if CF_Connecting_IP != "" { + //Use CF Connecting IP + clientIP = CF_Connecting_IP + } else if Fastly_Client_IP != "" { + //Use Fastly Client IP + clientIP = Fastly_Client_IP + } else { + ips := strings.Split(clientIP, ",") + if len(ips) > 0 { + clientIP = strings.TrimSpace(ips[0]) + } + } + } + + ip, _, err := net.SplitHostPort(clientIP) + if err != nil { + //Default allow passthrough on error + return nil + } + + router.rateLimitCounter.Increment(ip) + + if router.rateLimitCounter.Exceeded(ip, int64(pe.RateLimit)) { + w.WriteHeader(429) + return errors.New("rate limit exceeded") + } + + // log.Println("Rate limit check", ip, ipTable.GetCount(ip)) + + return nil +} + +// Start the ticker routine for reseting the rate limit counter every seconds +func (r *Router) startRateLimterCounterResetTicker() error { + if r.rateLimterStop != nil { + return errors.New("another rate limiter ticker already running") + } + tickerStopChan := make(chan bool) + r.rateLimterStop = tickerStopChan + + counterResetTicker := time.NewTicker(1 * time.Second) + go func() { + for { + select { + case <-tickerStopChan: + r.rateLimterStop = nil + return + case <-counterResetTicker.C: + r.rateLimitCounter.Clear() + } + } + }() + + return nil +} diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index 3386aa6..49c3253 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -51,8 +51,9 @@ type Router struct { tlsListener net.Listener routingRules []*RoutingRule - tlsRedirectStop chan bool //Stop channel for tls redirection server - tldMap map[string]int //Top level domain map, see tld.json + tlsRedirectStop chan bool //Stop channel for tls redirection server + rateLimterStop chan bool //Stop channel for rate limiter + rateLimitCounter RequestCountPerIpTable //Request counter for rate limter } // Auth credential for basic auth on certain endpoints @@ -124,6 +125,10 @@ type ProxyEndpoint struct { BasicAuthCredentials []*BasicAuthCredentials //Basic auth credentials BasicAuthExceptionRules []*BasicAuthExceptionRule //Path to exclude in a basic auth enabled proxy target + // Rate Limiting + RequireRateLimit bool + RateLimit int64 // Rate limit in requests per second + //Access Control AccessFilterUUID string //Access filter ID diff --git a/src/redirect.go b/src/redirect.go index 824cbe9..fa0a5c2 100644 --- a/src/redirect.go +++ b/src/redirect.go @@ -91,7 +91,7 @@ func handleToggleRedirectRegexpSupport(w http.ResponseWriter, r *http.Request) { //Update the current regex support rule enable state enableRegexSupport := strings.EqualFold(strings.TrimSpace(enabled), "true") redirectTable.AllowRegex = enableRegexSupport - err = sysdb.Write("Redirect", "regex", enableRegexSupport) + err = sysdb.Write("redirect", "regex", enableRegexSupport) if enableRegexSupport { SystemWideLogger.PrintAndLog("redirect", "Regex redirect rule enabled", nil) diff --git a/src/reverseproxy.go b/src/reverseproxy.go index e2113da..b2c164f 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -145,7 +145,6 @@ func ReverseProxtInit() { }) SystemWideLogger.Println("Uptime Monitor background service started") }() - } func ReverseProxyHandleOnOff(w http.ResponseWriter, r *http.Request) { @@ -229,6 +228,26 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { requireBasicAuth := (rba == "true") + // Require Rate Limiting? + rl, _ := utils.PostPara(r, "rate") + if rl == "" { + rl = "false" + } + requireRateLimit := (rl == "true") + rlnum, _ := utils.PostPara(r, "ratenum") + if rlnum == "" { + rlnum = "0" + } + proxyRateLimit, err := strconv.ParseInt(rlnum, 10, 64) + if err != nil { + utils.SendErrorResponse(w, "invalid rate limit number") + return + } + if proxyRateLimit <= 0 { + utils.SendErrorResponse(w, "rate limit number must be greater than 0") + return + } + // Bypass WebSocket Origin Check strbpwsorg, _ := utils.PostPara(r, "bpwsorg") if strbpwsorg == "" { @@ -309,6 +328,9 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) { BasicAuthExceptionRules: []*dynamicproxy.BasicAuthExceptionRule{}, DefaultSiteOption: 0, DefaultSiteValue: "", + // Rate Limit + RequireRateLimit: requireRateLimit, + RateLimit: proxyRateLimit, } preparedEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisProxyEndpoint) @@ -430,6 +452,26 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) { requireBasicAuth := (rba == "true") + // Rate Limiting? + rl, _ := utils.PostPara(r, "rate") + if rl == "" { + rl = "false" + } + requireRateLimit := (rl == "true") + rlnum, _ := utils.PostPara(r, "ratenum") + if rlnum == "" { + rlnum = "0" + } + proxyRateLimit, err := strconv.ParseInt(rlnum, 10, 64) + if err != nil { + utils.SendErrorResponse(w, "invalid rate limit number") + return + } + if proxyRateLimit <= 0 { + utils.SendErrorResponse(w, "rate limit number must be greater than 0") + return + } + // Bypass WebSocket Origin Check strbpwsorg, _ := utils.PostPara(r, "bpwsorg") if strbpwsorg == "" { @@ -451,6 +493,8 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) { newProxyEndpoint.BypassGlobalTLS = bypassGlobalTLS newProxyEndpoint.SkipCertValidations = skipTlsValidation newProxyEndpoint.RequireBasicAuth = requireBasicAuth + newProxyEndpoint.RequireRateLimit = requireRateLimit + newProxyEndpoint.RateLimit = proxyRateLimit newProxyEndpoint.SkipWebSocketOriginCheck = bypassWebsocketOriginCheck //Prepare to replace the current routing rule diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index b96dedc..36bc297 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -19,7 +19,7 @@ Host Destination Virtual Directory - Basic Auth + Advanced Settings Actions @@ -104,8 +104,9 @@ ${subd.Domain} ${tlsIcon} ${vdList} - - ${subd.RequireBasicAuth?``:``} + + ${subd.RequireBasicAuth?` Basic Auth`:` Basic Auth`}
+ ${subd.RequireRateLimit?` Rate Limit @ ${subd.RateLimit} req/s`:` Rate Limit`}
@@ -263,11 +264,11 @@ Edit Virtual Directories `); - }else if (datatype == "basicauth"){ + }else if (datatype == "advanced"){ let requireBasicAuth = payload.RequireBasicAuth; - let checkstate = ""; + let basicAuthCheckstate = ""; if (requireBasicAuth){ - checkstate = "checked"; + basicAuthCheckstate = "checked"; } let skipWebSocketOriginCheck = payload.SkipWebSocketOriginCheck; @@ -276,16 +277,36 @@ wsCheckstate = "checked"; } + let requireRateLimit = payload.RequireRateLimit; + let rateLimitCheckState = ""; + if (requireRateLimit){ + rateLimitCheckState = "checked"; + } + let rateLimit = payload.RateLimit; + if (rateLimit == 0){ + //This value is not set. Make it default to 100 + rateLimit = 100; + } + let rateLimitDisableState = ""; + if (!payload.RequireRateLimit){ + rateLimitDisableState = "disabled"; + } + column.empty().append(`
- +
- +
+ +
+ + +
- Advance Configs + Security Options
@@ -294,13 +315,33 @@ Check this to allow cross-origin websocket requests

- - +
+ + +

+
+ + +
`); + } else if (datatype == "ratelimit"){ + + column.empty().append(` +
+ + +
+
+ +
+ `); + }else if (datatype == 'action'){ column.empty().append(` @@ -331,6 +372,17 @@ $("#httpProxyList").find(".editBtn").addClass("disabled"); } + //handleToggleRateLimitInput will get trigger if the "require rate limit" checkbox + // is changed and toggle the disable state of the rate limit input field + function handleToggleRateLimitInput(){ + let isRateLimitEnabled = $("#httpProxyList input.RequireRateLimit")[0].checked; + if (isRateLimitEnabled){ + $("#httpProxyList input.RateLimit").parent().removeClass("disabled"); + }else{ + $("#httpProxyList input.RateLimit").parent().addClass("disabled"); + } + } + function exitProxyInlineEdit(){ listProxyEndpoints(); $("#httpProxyList").find(".editBtn").removeClass("disabled"); @@ -348,6 +400,8 @@ let requireTLS = $(row).find(".RequireTLS")[0].checked; let skipCertValidations = $(row).find(".SkipCertValidations")[0].checked; let requireBasicAuth = $(row).find(".RequireBasicAuth")[0].checked; + let requireRateLimit = $(row).find(".RequireRateLimit")[0].checked; + let rateLimit = $(row).find(".RateLimit").val(); let bypassGlobalTLS = $(row).find(".BypassGlobalTLS")[0].checked; let bypassWebsocketOrigin = $(row).find(".SkipWebSocketOriginCheck")[0].checked; console.log(newDomain, requireTLS, skipCertValidations, requireBasicAuth) @@ -364,6 +418,8 @@ "tlsval": skipCertValidations, "bpwsorg" : bypassWebsocketOrigin, "bauth" :requireBasicAuth, + "rate" :requireRateLimit, + "ratenum" :rateLimit, }, success: function(data){ if (data.error !== undefined){ @@ -438,10 +494,6 @@ }) } - /* Access List handling */ - - - //Bind on tab switch events tabSwitchEventBind["httprp"] = function(){ listProxyEndpoints(); diff --git a/src/web/components/rules.html b/src/web/components/rules.html index dd58a64..5948fa8 100644 --- a/src/web/components/rules.html +++ b/src/web/components/rules.html @@ -76,6 +76,22 @@
+
+
+ + +
+
+
+ +
+ +
+ req / sec / IP +
+
+ Return a 429 error code if request rate exceed the rate limit. +
@@ -150,6 +166,8 @@ var skipTLSValidation = $("#skipTLSValidation")[0].checked; var bypassGlobalTLS = $("#bypassGlobalTLS")[0].checked; var requireBasicAuth = $("#requireBasicAuth")[0].checked; + var proxyRateLimit = $("#proxyRateLimit").val(); + var requireRateLimit = $("#requireRateLimit")[0].checked; var skipWebSocketOriginCheck = $("#skipWebsocketOriginCheck")[0].checked; var accessRuleToUse = $("#newProxyRuleAccessFilter").val(); @@ -179,6 +197,8 @@ bpwsorg: skipWebSocketOriginCheck, bypassGlobalTLS: bypassGlobalTLS, bauth: requireBasicAuth, + rate: requireRateLimit, + ratenum: proxyRateLimit, cred: JSON.stringify(credentials), access: accessRuleToUse, }, @@ -267,6 +287,16 @@ } $("#requireBasicAuth").on('change', toggleBasicAuth); toggleBasicAuth(); + + function toggleRateLimit() { + if ($("#requireRateLimit").parent().checkbox("is checked")) { + $("#proxyRateLimit").parent().parent().removeClass("disabled"); + } else { + $("#proxyRateLimit").parent().parent().addClass("disabled"); + } + } + $("#requireRateLimit").on('change', toggleRateLimit); + toggleRateLimit(); /* @@ -400,11 +430,6 @@ initNewProxyRuleAccessDropdownList(); } - $(document).ready(function(){ - $("#advanceProxyRules").accordion(); - $("#newProxyRuleAccessFilter").parent().dropdown(); - }); - function openDockerContainersList(){ showSideWrapper('snippet/dockerContainersList.html'); } @@ -414,5 +439,9 @@ $('#proxyDomain').val(`${item.ip}:${item.port}`) hideSideWrapper(true); } + + $("#advanceProxyRules").accordion(); + $("#newProxyRuleAccessFilter").parent().dropdown(); + \ No newline at end of file diff --git a/src/web/components/status.html b/src/web/components/status.html index 9bfd908..3d187bb 100644 --- a/src/web/components/status.html +++ b/src/web/components/status.html @@ -74,7 +74,7 @@
-

+