Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ very large responses.
- A request header may be given as an array too: each value is sent as its own
header line, so a response table can be passed straight back.

### Name resolution

C dials through nginx's `resolver` directive and knows nothing else, so a host
application that owns a resolver installs it here:

```lua
client.set_resolver(function (host)
return ip, err -- nil plus an error surfaces as a connect error
end)
```

Every host that is not already an IP literal goes through it, on both entry
points, before anything crosses into C. The name is resolved first so the
address is what the connect and the keepalive pool key are built from: two
names on two addresses never share a pooled connection. What the peer sees
keeps the name: the `Host` header carries it, with the port whenever the client
would have written one, and the SNI is the name, so the certificate is still
judged against it. A caller-set `Host` or `ssl_server_name` still wins.

`set_resolver(nil)` removes it, and `resolver = <fn>` on a single
`request_uri` or `connect` overrides the installed one for that call.
`resolver = false` opts that call out and leaves the name to nginx.

In APISIX this is one call at init with `core.resolver.parse_domain`, which is
what makes this client agree with every cosocket in the gateway about
`/etc/hosts`, `dns_resolver` and the search domains (#45).

### Request validation

The method and every header name must be an RFC 9110 `token`, a header value
Expand Down
164 changes: 159 additions & 5 deletions lib/resty/ngx_http_ffi_client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ local pairs = pairs
local setmetatable = setmetatable
local string_lower = string.lower
local string_gsub = string.gsub
local string_find = string.find
local string_match = string.match
local table_concat = table.concat
local ngx_encode_args = ngx.encode_args
local co_yield = coroutine._yield
Expand Down Expand Up @@ -169,6 +171,119 @@ local function set_str(dst, value)
end


-- ===== name resolution =====
--
-- C dials through nginx's `resolver` directive and knows nothing else. A host
-- application that owns a resolver installs it here, and the name is resolved
-- on this side before anything crosses the FFI boundary: the address is what
-- the pool key and the connect are built from, while the Host header and the
-- SNI keep the name.


local resolver_hook


-- A hostname cannot hold a ':', so a colon means an IPv6 literal, bracketed or
-- not. The IPv4 literal is four decimal octets.
local function is_ip_literal(host)
if string_find(host, ":", 1, true) then
return true
end

local a, b, c, d = string_match(host, "^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then
return false
end

return tonumber(a) < 256 and tonumber(b) < 256
and tonumber(c) < 256 and tonumber(d) < 256
end


-- The address to dial. The name comes back unchanged when no resolver is
-- installed or it is already an address.
local function resolve_host(host, override)
local resolver = override
Comment on lines +205 to +206

if resolver == nil then
resolver = resolver_hook
end

if not resolver or is_ip_literal(host) then
return host
end

if type(resolver) ~= "function" then
return nil, "resolver must be a function"
end

-- C judges the host it is given, which after this is the address, so the
-- name is held to the same rule here: what C calls a VCHAR string, no
-- space, no control character. A resolver never launders an invalid host
-- into a Host header, where a space would be legal.
if string_find(host, "[%z\1-\32\127]") then
return nil, "invalid host"
end

local ip, err = resolver(host)

if type(ip) ~= "string" or ip == "" then
return nil, host .. " could not be resolved ("
.. (err or "no address") .. ")"
end

return ip
end


-- What C would have written from the name, so resolving changes the address
-- dialled and nothing the peer sees.
local function host_header_value(host, port)
if port == 80 then
return host
end

return host .. ":" .. port
end


-- The caller's table is left alone: the copy carries the name C can no longer
-- derive from the address. A caller-set Host wins, as it does everywhere else.
local function with_host_header(headers, value)
local out = {}

if headers ~= nil then
if type(headers) ~= "table" then
return headers
end

for key, header_value in pairs(headers) do
if type(key) == "string" and string_lower(key) == "host" then
return headers
end

out[key] = header_value
end
end

out["Host"] = value

return out
end


-- set_resolver(fn) installs the resolver every non-IP host goes through;
-- set_resolver(nil) removes it. fn(host) returns an address, or nil and an
-- error that surfaces as a connect error.
function _M.set_resolver(fn)
if fn ~= nil and type(fn) ~= "function" then
error("resolver must be a function or nil", 2)
end

resolver_hook = fn
end


-- Host, Connection and Content-Length reach C and feed the request prologue.
-- Transfer-Encoding stays out: this client always frames the body with a
-- Content-Length, and both on the wire is the request-smuggling shape (#29).
Expand Down Expand Up @@ -343,6 +458,20 @@ function _M.request_uri(opts)
return nil, "ssl_trusted_certificate must be a string"
end

-- resolved after the SNI has taken the name and before the pool key is
-- derived from the address: the certificate is still judged against the
-- name, and two names on two addresses keep separate pools
local host, resolve_err = resolve_host(opts.host, opts.resolver)
if not host then
return nil, resolve_err
end

local headers = opts.headers
if host ~= opts.host then
headers = with_host_header(headers,
host_header_value(opts.host, port))
end

local method = opts.method or "GET"
local path = opts.path or "/"
local body = opts.body
Expand Down Expand Up @@ -379,7 +508,7 @@ function _M.request_uri(opts)
end

local header_arr, header_count, header_refs, header_err =
build_headers(opts.headers)
build_headers(headers)

if header_err then
return nil, header_err
Expand All @@ -389,7 +518,7 @@ function _M.request_uri(opts)
local resp = resp_t()
local refs = {
set_str(req.scheme, scheme),
set_str(req.host, opts.host),
set_str(req.host, host),
set_str(req.method, method),
set_str(req.path, path),
set_str(req.body, body),
Expand Down Expand Up @@ -550,7 +679,7 @@ function client.connect(self, opts, port_arg)
error("no request found", 2)
end

local host, port, scheme, pool, pool_size
local host, port, scheme, pool, pool_size, resolver
local ssl_verify, ssl_server_name, ssl_trusted_certificate

if type(opts) == "table" then
Expand All @@ -559,6 +688,7 @@ function client.connect(self, opts, port_arg)
scheme = opts.scheme or "http"
pool = opts.pool
pool_size = opts.pool_size
resolver = opts.resolver
ssl_verify = opts.ssl_verify
ssl_server_name = opts.ssl_server_name
ssl_trusted_certificate = opts.ssl_trusted_certificate
Expand Down Expand Up @@ -615,6 +745,24 @@ function client.connect(self, opts, port_arg)
end
end

-- the name is what the SNI and the Host header keep; the address is what
-- the pool key and the connect are built from
local name = host
local resolve_err

host, resolve_err = resolve_host(name, resolver)
if not host then
return nil, resolve_err
end

-- held until the connect is accepted: a second connect on a live object is
-- refused with the first connection intact, and that connection keeps the
-- Host it was opened with
local host_header
if host ~= name then
host_header = host_header_value(name, port)
end

if self._op == nil then
local op = c_new(r)
if op == nil then
Expand All @@ -632,7 +780,7 @@ function client.connect(self, opts, port_arg)
set_str(cp.scheme, scheme),
set_str(cp.host, host),
set_str(cp.keepalive_pool, pool),
set_str(cp.ssl_server_name, ssl and (ssl_server_name or host) or nil),
set_str(cp.ssl_server_name, ssl and (ssl_server_name or name) or nil),
set_str(cp.ssl_trusted_certificate,
ssl and ssl_trusted_certificate or nil),
}
Expand Down Expand Up @@ -662,6 +810,7 @@ function client.connect(self, opts, port_arg)
end

self._connected = true
self._host_header = host_header

return 1
end
Expand Down Expand Up @@ -807,8 +956,13 @@ function client.request(self, params)
body = tostring(body)
end

local headers = params.headers
if self._host_header then
headers = with_host_header(headers, self._host_header)
end

local header_arr, header_count, header_refs, header_err =
build_headers(params.headers)
build_headers(headers)

if header_err then
return nil, header_err
Expand Down
Loading
Loading