Every request to a host with additional load-balancing targets 500'd:
targetinfo.lua required 'resty.balancer.round_robin', which does not
exist in the lua-resty-balancer rock actually installed by the
Dockerfile/install.sh. That rock provides resty.roundrobin instead,
with a different constructor (roundrobin:new(nodes), not
:new() + :reinit(nodes)).
Verified end-to-end in a rebuilt image: requests to a load-balanced
host now return 200 and alternate across both backend targets, with
no Lua errors in the OpenResty log.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
- ops/nginx_conf/targetinfo.lua's wildcard-subdomain lookup fallback used
classic LuaSocket (require("socket.unix")) instead of an OpenResty
cosocket. LuaSocket is blocking, and called from an nginx worker it
stalls the ENTIRE worker — every other in-flight connection on it — for
the round-trip to the Node app. Worse, the Node side never
newline-terminated its response, so the old blocking receive() only ever
returned via its read-timeout-then-partial-read fallback, meaning every
single cache-miss lookup paid a fixed timeout penalty while blocking the
whole worker. Replaced with an ngx.socket.tcp() cosocket (unix-domain via
"unix:/path", the only cosocket API this lua-nginx-module ships) and
newline-terminated the Node service's responses so receive() actually
completes instead of timing out. Verified against a live container:
previously this crashed OpenResty's Lua VM entirely
(ngx.socket.unix doesn't exist); fixed version resolves fresh wildcard
subdomains in ~2ms.
- Add gzip compression (`compression` middleware) and far-future
Cache-Control on static assets (7d for vendor libs under
/static-modules, 1h for the app's own /static JS/CSS, which isn't
cache-busted). The admin UI is a traditional multi-page app that loads
~13 separate vendor/app JS+CSS files on every full navigation; previously
none of them were compressed and Cache-Control was `max-age=0` (Express's
default), forcing a revalidation round-trip for every asset on every page
view.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported: fallback SSL doesn't work in the Docker build. Reproduced —
it's worse than the fallback specifically: TLS was broken for nearly
every connection, including ones with no SNI at all:
$ curl -vk https://127.0.0.1/
* TLSv1.3 (IN), TLS alert, internal error (592)
* OpenSSL/3.0.13: error:0A000438:SSL routines::tlsv1 alert internal error
Root cause: targetinfo.lua's M.get() is shared by two call sites in
two incompatible nginx phases —
- proxy.conf's access_by_lua_block (a normal HTTP request phase,
where ngx.exit() is valid)
- nginx.conf's request_domain callback, which runs during the TLS
handshake itself (ssl_certificate_by_lua*), where ngx.exit() is
NOT a supported API
M.get() called ngx.exit() on every lookup failure (no domain/SNI, a
Redis error, or an unregistered host). When invoked from the SSL
phase, that aborted the handshake with a bare "internal error" alert
and produced no log output anywhere — silent and total, not limited
to the unregistered-domain case, since even a connection with no SNI
hits the same code path immediately.
Fix: M.get() no longer calls ngx.exit() itself — it returns
(nil, httpStatus) on failure. proxy.conf now checks the return value
and calls ngx.exit() itself (the phase where that's actually
supported). nginx.conf's request_domain guards the now-possibly-nil
result before indexing it, and leaves ngx.ctx.toAllow unset on
failure so allow_domain() correctly denies issuance and auto-ssl
falls through to the static fallback cert in autossl.conf.
Verified end to end against a running Docker build (deployed the
changed files into a live container and reloaded, rather than relying
on a full rebuild each iteration):
- No SNI at all: TLS now completes; HTTP layer correctly returns 406
(previously: broken handshake, no response at all)
- Unregistered SNI: same — TLS completes, 406, and openssl s_client
confirms the cert served is genuinely the fallback
(CN=sni-support-required-for-valid-ssl)
- A real registered Host: TLS completes and proxies through to the
backend correctly (confirms the success path is unaffected)
- npm test: 192/192 pass
Hosts that share one wildcard cert (e.g. hassio.718it.biz and
metrics.718it.biz under *.718it.biz) resolve to the same IP, so browsers
coalesce them onto a single HTTP/2 connection. The SSL request_domain
phase resolves the connection's first host and caches it in
ngx.ctx.targetInfo; the unguarded `if targetInfo then return targetInfo`
then handed that first host's target to every coalesced request on the
connection -- e.g. hassio.718it.biz served from metrics' 192.168.1.8:8088.
Confirmed via debug logging: for GET requests to hassio.718it.biz on a
coalesced connection, ngx.ctx already held metrics' IP and the function
short-circuited to it.
Guard the reuse by host: only return the cached target when
ngx.ctx.targetInfo_domain matches the requested domain, and record that
domain whenever a target is resolved. A coalesced request for a different
host now re-resolves against its actual Host header.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- host.remove() now deletes the cert under the host key instead of the
Domain relation object, so certs are actually removed from redis
- targetinfo.lua returns the redis connection to the pool via
set_keepalive instead of leaking one connection per request
- autossl.conf drops TLSv1/1.1 and 3DES, adds TLSv1.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>