feat: Add multi-target load balancing support

Fixes #47.
- Added lua-resty-balancer to dependencies (Dockerfile & install.sh).
- Added 'targets' field to the Host model to hold additional targets.
- Updated the UI to allow inputting additional targets (IP:port).
- Updated targetinfo.lua to parse the additional targets and load balance between them and the primary target using resty.balancer.round_robin.
This commit is contained in:
2026-07-21 00:48:43 -04:00
parent 21e295615b
commit 4321826dc8
6 changed files with 53 additions and 0 deletions
+1
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+1
View File
@@ -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'},
+1
View File
@@ -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);
+9
View File
@@ -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 @@
<small class="field-help text-muted d-block">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.</small>
</div>
</div>
<hr>
<div class="form-group">
<label for="targets" class="form-label">Additional Targets (Load Balancing)</label>
<textarea name="targets" class="form-control" rows="2" placeholder="10.0.0.2:8080&#10;10.0.0.3:8080"></textarea>
<small class="field-help text-muted d-block">Add additional targets here (IP:port, one per line) to load balance across them using round-robin. The primary target above is always included.</small>
</div>
</div>
<!-- TLS & Wildcard -->
+1
View File
@@ -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
+40
View File
@@ -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