diff --git a/Dockerfile b/Dockerfile index 1bf5b27..9ef111e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,6 +83,7 @@ RUN apt-get update \ # resty.limit.req is bundled with OpenResty, so no rock is needed for it. RUN luarocks install lua-resty-auto-ssl \ && luarocks install luasocket \ + && luarocks install lua-resty-balancer \ && luarocks install lua-resty-ipmatcher # ── Node app ───────────────────────────────────────────────────────────────── diff --git a/nodejs/models/host.js b/nodejs/models/host.js index 5abc762..fd88a5f 100755 --- a/nodejs/models/host.js +++ b/nodejs/models/host.js @@ -28,6 +28,7 @@ class Host extends Table{ 'host': {isRequired: true, type: 'string', min: 1, max: 500}, 'ip': {isRequired: true, type: 'string', min: 3, max: 500}, 'targetPort': {isRequired: true, type: 'number', min:0, max:65535}, + 'targets': {default: function(){return []}, isRequired: false, type: 'object'}, 'forcessl': {isRequired: false, default: true, type: 'boolean'}, 'targetssl': {isRequired: false, default: false, type: 'boolean'}, diff --git a/nodejs/utils/host_features.js b/nodejs/utils/host_features.js index 4e5cc83..32ddc9d 100644 --- a/nodejs/utils/host_features.js +++ b/nodejs/utils/host_features.js @@ -252,6 +252,7 @@ function normalizeHostFeatures(body){ if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled); if('sso_allow_users' in body) body.sso_allow_users = parseAllowList(body.sso_allow_users); if('sso_allow_groups' in body) body.sso_allow_groups = parseAllowList(body.sso_allow_groups); + if('targets' in body) body.targets = parseAllowList(body.targets); if('ratelimit_rate' in body) body.ratelimit_rate = clampNumber(body.ratelimit_rate, 1, 1000000, 10); if('ratelimit_burst' in body) body.ratelimit_burst = clampNumber(body.ratelimit_burst, 0, 1000000, 20); diff --git a/nodejs/views/hosts.ejs b/nodejs/views/hosts.ejs index 9a61c93..fa18728 100755 --- a/nodejs/views/hosts.ejs +++ b/nodejs/views/hosts.ejs @@ -232,6 +232,7 @@ }); $f.find("textarea[name='req_headers']").val(hostFeatureHeadersToText(h.req_headers)); + $f.find("textarea[name='targets']").val(hostFeatureListToText(h.targets)); $f.find("textarea[name='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers)); $f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow)); $f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny)); @@ -632,6 +633,14 @@ Whether the proxy talks to the target over HTTP or HTTPS. Independent of Incoming SSL above — clients can use HTTPS to reach the proxy while it still talks plain HTTP to the target, or vice versa. + +
+ +
+ + + Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included. +
diff --git a/ops/install.sh b/ops/install.sh index 7d69b48..635405e 100755 --- a/ops/install.sh +++ b/ops/install.sh @@ -123,6 +123,7 @@ apt-get install -y nodejs openresty echo "==> Lua modules" luarocks install lua-resty-auto-ssl luarocks install luasocket +luarocks install lua-resty-balancer # CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua). # resty.limit.req is bundled with OpenResty, so no rock is needed for it. luarocks install lua-resty-ipmatcher diff --git a/ops/nginx_conf/targetinfo.lua b/ops/nginx_conf/targetinfo.lua index cb4fb9c..4c5d0a3 100644 --- a/ops/nginx_conf/targetinfo.lua +++ b/ops/nginx_conf/targetinfo.lua @@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo) local json = require "cjson" local redis = require "resty.redis" + local round_robin = require "resty.balancer.round_robin" if not domain then return nil, 499 @@ -95,6 +96,45 @@ function M.get(ngx, domain, targetInfo) return nil, 406 end + -- Load balancing + local target_list = {} + table.insert(target_list, res["ip"] .. ":" .. tostring(res["targetPort"])) + + if res["targets"] and res["targets"] ~= "" and res["targets"] ~= "[]" then + local decodeOk, decodedTargets = pcall(json.decode, res["targets"]) + if decodeOk and type(decodedTargets) == "table" then + for _, t in ipairs(decodedTargets) do + table.insert(target_list, t) + end + end + end + + if #target_list > 1 then + if not M.host_balancers then M.host_balancers = {} end + local cache_key = domain .. "_" .. (res["updated_on"] or "0") + + if not M.host_balancers[domain] or M.host_balancers[domain].key ~= cache_key then + local b = round_robin:new() + local nodes = {} + for _, t in ipairs(target_list) do + nodes[t] = 1 + end + b:reinit(nodes) + M.host_balancers[domain] = { b = b, key = cache_key } + end + + local peer = M.host_balancers[domain].b:find() + if peer then + local colon = peer:find(":") + if colon then + res["ip"] = peer:sub(1, colon - 1) + res["targetPort"] = peer:sub(colon + 1) + else + res["ip"] = peer + end + end + end + ngx.ctx.targetInfo = res -- Remember which host this target was resolved for, so the reuse guard at -- the top can tell a genuine cache hit from a coalesced request for a