Compare commits

...

8 Commits

Author SHA1 Message Date
wmantly 93cf034e61 Merge pull request #173 from theta42/docs/load-balancing
docs: Document load balancing feature
2026-07-21 00:59:09 -04:00
wmantly 2102b309de docs: Document load balancing feature 2026-07-21 00:58:27 -04:00
wmantly c3fe25335f Merge pull request #172 from theta42/feature/multi-target-load-balancing
feat: Multi-target load balancing support
2026-07-21 00:49:24 -04:00
wmantly 4321826dc8 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.
2026-07-21 00:48:43 -04:00
wmantly 21e295615b Merge pull request #171 from theta42/release-1.1.17
Bump version to 1.1.17; update CHANGELOG
2026-07-20 00:29:06 -04:00
wmantly 7452ccd655 Bump version to 1.1.17; update CHANGELOG
Move the wildcard sibling-parent fix into its own 1.1.17 section (it is not
in the v1.1.16 tag) and bump nodejs/package.json + lockfile to 1.1.17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:27:48 -04:00
wmantly 5acea6fcc2 Merge pull request #170 from theta42/release/public-release-readiness
Fix attaching an existing sibling subdomain to a parent wildcard
2026-07-20 00:18:31 -04:00
wmantly fcd97b12aa Fix attaching an existing sibling subdomain to a parent wildcard
Host.lookUpWildcardParent() walked all labels of the host down to its own
leaf and only inspected that leaf's "*" child, so it found a wildcard
nested under the host (the base-domain case, e.g. *.cool.mysite.com for
cool.mysite.com) but missed the common case where the wildcard is a
SIBLING of the host's leftmost label (e.g. *.nl.wgnode.com covering an
already-existing sso.nl.wgnode.com). The /wildcard-parent route then
returned nothing and the edit form's "Parent Wildcard" option stayed
greyed out, leaving no way to convert an existing auto-SSL host onto a
wildcard issued afterward.

Track the parent node during the walk and check the sibling "*" slot too.
The never-created-subdomain case is unchanged (plain lookUp()'s wildcard
fallback in the route still handles it). Sync the test mock and add
regression coverage for the sibling case (existing host, never-created
host via the route fallback, and a deeper-wildcard negative case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:13:13 -04:00
13 changed files with 156 additions and 19 deletions
+12 -1
View File
@@ -6,6 +6,11 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased] ## [Unreleased]
## [1.1.17] - 2026-07-20
### Fixed
- An existing single-label subdomain host (e.g. `sso.nl.wgnode.com`) could not be attached to a wildcard cert added later (e.g. `*.nl.wgnode.com`): `Host.lookUpWildcardParent()` only checked the wildcard-as-child position (the wildcard's own base domain) and missed the far more common wildcard-as-sibling case, so the edit form's "Parent Wildcard" option stayed permanently greyed out. It now checks both positions, and a regression test covers the sibling case.
## [1.1.16] - 2026-07-18 ## [1.1.16] - 2026-07-18
### Changed ### Changed
@@ -13,6 +18,10 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
- Genericized committed defaults in `conf/base.js` and `conf/development.js`: LDAP now defaults to `ldap://localhost` with `dc=example,dc=com`, and OIDC endpoints default to `https://sso.example.com` instead of internal theta42 infrastructure. - Genericized committed defaults in `conf/base.js` and `conf/development.js`: LDAP now defaults to `ldap://localhost` with `dc=example,dc=com`, and OIDC endpoints default to `https://sso.example.com` instead of internal theta42 infrastructure.
- The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default `proxyadmin2`. The password is printed to the log on first creation and can be made deterministic by setting `auth.localAdminPass` in the secrets file. - The bootstrap `proxyadmin2` account now gets a random, one-time password when `auth.localAdminPass` is unset, instead of the well-known default `proxyadmin2`. The password is printed to the log on first creation and can be made deterministic by setting `auth.localAdminPass` in the secrets file.
### Security
- Sanitized rendered docs HTML via `xss` in `routes/docs.js` so malicious markdown cannot inject scripts or other dangerous markup into the in-app docs viewer.
- The Unix socket JSON-RPC socket is now created with mode `660` instead of world-writable `777`.
### Fixed ### Fixed
- The global error handler no longer leaks `err.keys`, stack traces, or other internal details in JSON responses; only `name` and `message` are returned to clients. - The global error handler no longer leaks `err.keys`, stack traces, or other internal details in JSON responses; only `name` and `message` are returned to clients.
- `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior. - `DEPLOYMENT.md` and `docs/docker.md` now correctly describe the `CONF_SECRETS` env-var mechanism instead of the old symlink behavior.
@@ -123,7 +132,9 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention. - Standalone backup script (`ops/backup.sh`) for deployments not using theta-env's orchestrator — snapshots Redis and `./config`, with retention.
- Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates. - Admin-only in-app banner that checks GitHub releases every 24h and surfaces available updates.
[Unreleased]: https://github.com/theta42/proxy/compare/v1.1.15...HEAD [Unreleased]: https://github.com/theta42/proxy/compare/v1.1.17...HEAD
[1.1.17]: https://github.com/theta42/proxy/compare/v1.1.16...v1.1.17
[1.1.16]: https://github.com/theta42/proxy/compare/v1.1.15...v1.1.16
[1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15 [1.1.15]: https://github.com/theta42/proxy/compare/v1.1.14...v1.1.15
[1.1.14]: https://github.com/theta42/proxy/compare/v1.1.13...v1.1.14 [1.1.14]: https://github.com/theta42/proxy/compare/v1.1.13...v1.1.14
[1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13 [1.1.13]: https://github.com/theta42/proxy/compare/v1.1.12...v1.1.13
+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. # resty.limit.req is bundled with OpenResty, so no rock is needed for it.
RUN luarocks install lua-resty-auto-ssl \ RUN luarocks install lua-resty-auto-ssl \
&& luarocks install luasocket \ && luarocks install luasocket \
&& luarocks install lua-resty-balancer \
&& luarocks install lua-resty-ipmatcher && luarocks install lua-resty-ipmatcher
# ── Node app ───────────────────────────────────────────────────────────────── # ── Node app ─────────────────────────────────────────────────────────────────
+1
View File
@@ -51,6 +51,7 @@ provider + LDAP directory you already run.
- Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free) - Multiple DNS provider integrations (Cloudflare, DigitalOcean, PorkBun, DuckDNS — DuckDNS is free)
- Wildcard SSL certificate support with automatic renewal - Wildcard SSL certificate support with automatic renewal
- Dynamic host routing with wildcard domain matching (*, **) - Dynamic host routing with wildcard domain matching (*, **)
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
- Web-based management interface - Web-based management interface
- RESTful API for automation - RESTful API for automation
- **OIDC login** — the proxy is an OpenID Connect client of an external SSO - **OIDC login** — the proxy is an OpenID Connect client of an external SSO
+4
View File
@@ -68,6 +68,10 @@ host form whenever the name you're entering already has a matching
wildcard available to reuse — including the wildcard's own bare base wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`). domain (e.g. `example.com` itself, not just `something.example.com`).
## Load Balancing
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
## Want more detail? ## Want more detail?
This page skips the system-internals (Redis, OpenResty, the lookup service) This page skips the system-internals (Redis, OpenResty, the lookup service)
+1
View File
@@ -50,6 +50,7 @@ LDAP directory you already run.
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges - Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free) - Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
- Dynamic host routing with wildcard domain matching (`*`, `**`) - Dynamic host routing with wildcard domain matching (`*`, `**`)
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
- **OIDC login** and **direct LDAP lookups**, independently of each other - **OIDC login** and **direct LDAP lookups**, independently of each other
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so - Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
it's never ambiguous which one gated a request) it's never ambiguous which one gated a request)
+28 -13
View File
@@ -28,6 +28,7 @@ class Host extends Table{
'host': {isRequired: true, type: 'string', min: 1, max: 500}, 'host': {isRequired: true, type: 'string', min: 1, max: 500},
'ip': {isRequired: true, type: 'string', min: 3, max: 500}, 'ip': {isRequired: true, type: 'string', min: 3, max: 500},
'targetPort': {isRequired: true, type: 'number', min:0, max:65535}, 'targetPort': {isRequired: true, type: 'number', min:0, max:65535},
'targets': {default: function(){return []}, isRequired: false, type: 'object'},
'forcessl': {isRequired: false, default: true, type: 'boolean'}, 'forcessl': {isRequired: false, default: true, type: 'boolean'},
'targetssl': {isRequired: false, default: false, type: 'boolean'}, 'targetssl': {isRequired: false, default: false, type: 'boolean'},
@@ -518,24 +519,38 @@ class Host extends Table{
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record']; if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
} }
// Find the wildcard covering @host as its own base domain (e.g. // Find the wildcard that could cover @host, regardless of whether @host is
// "*.cool.mysite.com" for host="cool.mysite.com"), regardless of whether // already registered as its own host. Unlike lookUp(), which walks to and
// @host is already registered as its own host. Unlike lookUp(), which // returns @host's own exact-match leaf when one exists, this keeps looking
// walks to and returns @host's own exact-match leaf when one exists, this // for a sibling/child "*" slot, so it still finds the parent wildcard even
// walks to that exact position and looks one level deeper at its "*" // when @host already has its own (non-wildcard) record. Used when attaching
// child -- the sibling wildcard slot -- so it still finds the parent // an already-created host to a wildcard after the fact (see update() below);
// wildcard even when @host already has its own (non-wildcard) record. // Host.create()'s own wildcardChild handling can keep using plain lookUp()
// Used when attaching an already-created host to a wildcard after the // since a host being newly created hasn't claimed its own leaf yet.
// fact (see update() below); Host.create()'s own wildcardChild handling //
// can keep using plain lookUp() since a host being newly created hasn't // Two tree positions qualify, and we must check BOTH:
// claimed its own leaf yet. // 1. Child "*" of @host's own node -- @host is the wildcard's base domain
// (e.g. "*.cool.mysite.com" covers host="cool.mysite.com").
// 2. Sibling "*" one level up -- @host is a single-label subdomain of the
// wildcard (e.g. "*.nl.wgnode.com" covers host="sso.nl.wgnode.com").
// Case 2 is the common one and was previously missed: the walk consumed the
// leftmost label ("sso") and only inspected that leaf's "*" child, so an
// already-existing sibling subdomain could never be attached to its wildcard.
static lookUpWildcardParent(host){ static lookUpWildcardParent(host){
let place = this.lookUpObj; let place = this.lookUpObj;
let parent = undefined;
for(let fragment of host.split('.').reverse()){ for(let fragment of host.split('.').reverse()){
if(!place[fragment]) return undefined; // @host may have no leaf of its own (brand-new subdomain); that case
// is already handled by plain lookUp()'s wildcard fallback in the
// caller, so just stop -- we've still tracked `parent` for case 2.
if(!place[fragment]){ place = undefined; break; }
parent = place;
place = place[fragment]; place = place[fragment];
} }
if(place['*'] && place['*']['#record']) return place['*']['#record']; // Case 1: wildcard is a child of @host's own node.
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
// Case 2: wildcard is a sibling of @host's leftmost label.
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
} }
static async lookUpReady(){ static async lookUpReady(){
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.16", "version": "1.1.17",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.16", "version": "1.1.17",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxy-api", "name": "proxy-api",
"version": "1.1.16", "version": "1.1.17",
"author": [ "author": [
{ {
"name": "William Mantly", "name": "William Mantly",
+55 -2
View File
@@ -185,6 +185,35 @@ describe('Host wildcard base-domain lookup', () => {
await populateTree(Host, ['*.cool.mysite.com']); await populateTree(Host, ['*.cool.mysite.com']);
assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined); assert.strictEqual(Host.lookUpWildcardParent('other.example.com'), undefined);
}); });
// Regression: the common case -- an already-existing single-label subdomain
// (its own auto-SSL/HTTP-01 host) sitting beside a wildcard, e.g.
// sso.nl.wgnode.com under *.nl.wgnode.com. The wildcard is a SIBLING of the
// subdomain's leftmost label, not a child of its node, so the old walk (which
// consumed "sso" and only checked that leaf's "*" child) never found it and
// the edit form's "Parent Wildcard" option stayed permanently greyed out.
test('lookUpWildcardParent finds a sibling wildcard for an existing single-label subdomain', async () => {
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
const result = Host.lookUpWildcardParent('sso.nl.wgnode.com');
assert.ok(result, 'Should find the sibling wildcard');
assert.strictEqual(result.host, '*.nl.wgnode.com');
});
// A subdomain with no leaf of its own (never created) is deliberately NOT
// this method's job -- the walk stops before reaching the sibling "*" slot.
// The route resolves that case via plain lookUp()'s wildcard fallback first
// (covered in the route-fallback describe block below).
test('lookUpWildcardParent returns undefined for a subdomain with no leaf of its own', async () => {
await populateTree(Host, ['*.nl.wgnode.com']);
assert.strictEqual(Host.lookUpWildcardParent('api.nl.wgnode.com'), undefined);
});
test('lookUpWildcardParent does not treat a deeper wildcard as covering a shallower host', async () => {
// *.deep.nl.wgnode.com must NOT be offered as a parent for sso.nl.wgnode.com
// (a single-level wildcard covers only its own direct children).
await populateTree(Host, ['sso.nl.wgnode.com', '*.deep.nl.wgnode.com']);
assert.strictEqual(Host.lookUpWildcardParent('sso.nl.wgnode.com'), undefined);
});
}); });
/** /**
@@ -231,6 +260,24 @@ describe('Host wildcard-parent route fallback (lookUp then lookUpWildcardParent)
await populateTree(Host, ['cool.mysite.com']); await populateTree(Host, ['cool.mysite.com']);
assert.strictEqual(findWildcardParent('cool.mysite.com'), null); assert.strictEqual(findWildcardParent('cool.mysite.com'), null);
}); });
// The user's scenario: sso.nl.wgnode.com already exists as its own host, and
// a *.nl.wgnode.com wildcard is added afterward. lookUp() resolves to sso's
// own (non-wildcard) leaf, so the fallback to lookUpWildcardParent() is what
// surfaces the sibling wildcard and lets the edit form offer conversion.
test('finds the sibling wildcard for an already-existing single-label subdomain', async () => {
await populateTree(Host, ['sso.nl.wgnode.com', '*.nl.wgnode.com']);
const result = findWildcardParent('sso.nl.wgnode.com');
assert.ok(result);
assert.strictEqual(result.host, '*.nl.wgnode.com');
});
test('finds the sibling wildcard for a never-created single-label subdomain', async () => {
await populateTree(Host, ['*.nl.wgnode.com']);
const result = findWildcardParent('api.nl.wgnode.com');
assert.ok(result);
assert.strictEqual(result.host, '*.nl.wgnode.com');
});
}); });
/** /**
@@ -265,11 +312,17 @@ function createMockHostClassWithWildcardParentFix() {
static lookUpWildcardParent(host) { static lookUpWildcardParent(host) {
let place = this.lookUpObj; let place = this.lookUpObj;
let parent = undefined;
for(let fragment of host.split('.').reverse()){ for(let fragment of host.split('.').reverse()){
if(!place[fragment]) return undefined; if(!place[fragment]){ place = undefined; break; }
parent = place;
place = place[fragment]; place = place[fragment];
} }
if(place['*'] && place['*']['#record']) return place['*']['#record']; // Case 1: wildcard is a child of host's own node (base domain).
if(place && place['*'] && place['*']['#record']) return place['*']['#record'];
// Case 2: wildcard is a sibling of host's leftmost label
// (single-label subdomain, e.g. sso.nl.wgnode.com -> *.nl.wgnode.com).
if(parent && parent['*'] && parent['*']['#record']) return parent['*']['#record'];
} }
}; };
} }
+1
View File
@@ -252,6 +252,7 @@ function normalizeHostFeatures(body){
if('sso_enabled' in body) body.sso_enabled = toBool(body.sso_enabled); 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_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('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_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); 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='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='resp_headers']").val(hostFeatureHeadersToText(h.resp_headers));
$f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow)); $f.find("textarea[name='ip_allow']").val(hostFeatureListToText(h.ip_allow));
$f.find("textarea[name='ip_deny']").val(hostFeatureListToText(h.ip_deny)); $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> <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>
</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> </div>
<!-- TLS & Wildcard --> <!-- TLS & Wildcard -->
+1
View File
@@ -123,6 +123,7 @@ apt-get install -y nodejs openresty
echo "==> Lua modules" echo "==> Lua modules"
luarocks install lua-resty-auto-ssl luarocks install lua-resty-auto-ssl
luarocks install luasocket luarocks install luasocket
luarocks install lua-resty-balancer
# CIDR matcher for the per-host IP allow/deny lists (hostfeatures.lua). # 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. # resty.limit.req is bundled with OpenResty, so no rock is needed for it.
luarocks install lua-resty-ipmatcher luarocks install lua-resty-ipmatcher
+40
View File
@@ -62,6 +62,7 @@ function M.get(ngx, domain, targetInfo)
local json = require "cjson" local json = require "cjson"
local redis = require "resty.redis" local redis = require "resty.redis"
local round_robin = require "resty.balancer.round_robin"
if not domain then if not domain then
return nil, 499 return nil, 499
@@ -95,6 +96,45 @@ function M.get(ngx, domain, targetInfo)
return nil, 406 return nil, 406
end 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 ngx.ctx.targetInfo = res
-- Remember which host this target was resolved for, so the reuse guard at -- 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 -- the top can tell a genuine cache hit from a coalesced request for a