Fix TLS handshake failure for any host without a cached target (#132)

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
This commit is contained in:
2026-07-14 21:33:48 -04:00
committed by GitHub
parent 8aab9c7673
commit 22f382b968
3 changed files with 26 additions and 10 deletions
+7 -1
View File
@@ -51,9 +51,15 @@ http {
auto_ssl:set("request_domain", function(ssl, ssl_options)
local domain, err = ssl.server_name()
-- targetInfo.get() returns (nil, httpStatus) for an unknown/unregistered
-- domain rather than erroring -- that's expected here (e.g. a probe with
-- no matching Host record, or no SNI at all). ngx.ctx.toAllow is only
-- set on a successful lookup, so allow_domain() correctly denies
-- issuance and auto-ssl falls back to the static ssl_certificate in
-- autossl.conf.
local res = targetInfo.get(ngx, domain, ngx.ctx.targetInfo)
if res['wildcard_parent'] then
if res and res['wildcard_parent'] then
return res['wildcard_parent'], err
end
+4 -1
View File
@@ -39,7 +39,10 @@ server {
local host = ngx.var.host
local uri = ngx.var.uri
local scheme = ngx.var.scheme
local res = targetInfo.get(ngx, host, ngx.ctx.targetInfo)
local res, errCode = targetInfo.get(ngx, host, ngx.ctx.targetInfo)
if not res then
return ngx.exit(errCode or 500)
end
if scheme == "http" then
if res["forcessl"] == "true" then
+13 -6
View File
@@ -11,7 +11,16 @@ end
print("In targetInfo module")
-- Main function of the module
-- Main function of the module. Returns (res) on success, or (nil, httpStatus)
-- on failure -- it must NOT call ngx.exit() itself: this is called both from
-- proxy.conf's access_by_lua_block (a normal request phase, where ngx.exit()
-- is fine) AND from nginx.conf's request_domain callback, which runs during
-- the TLS handshake (ssl_certificate_by_lua*). ngx.exit() is not a supported
-- API in that phase -- calling it there aborts the handshake with a bare
-- "internal error" TLS alert and no log output, breaking TLS entirely
-- (including the self-signed fallback cert, since auto-ssl never gets to
-- fall back gracefully). Callers that can legitimately abort the request
-- (i.e. proxy.conf) must call ngx.exit() themselves using the returned status.
function M.get(ngx, domain, targetInfo)
-- Reuse a previously-resolved target ONLY when it was resolved for this
-- exact host. HTTP/2 connection coalescing lets a browser serve several
@@ -28,8 +37,7 @@ function M.get(ngx, domain, targetInfo)
local redis = require "resty.redis"
if not domain then
ngx.exit(499)
return false
return nil, 499
end
local red = redis:new()
@@ -38,7 +46,7 @@ function M.get(ngx, domain, targetInfo)
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "failed to connect to redis: ", err)
return ngx.exit(598)
return nil, 598
end
local res, err = red:hgetall("proxy_Host_"..domain)
@@ -70,8 +78,7 @@ function M.get(ngx, domain, targetInfo)
end
if not res["ip"] then
ngx.exit(406)
return false
return nil, 406
end
ngx.ctx.targetInfo = res