-
Notifications
You must be signed in to change notification settings - Fork 170
/
Copy pathproxy.lua
439 lines (345 loc) · 13.4 KB
/
proxy.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
------------
-- Proxy
-- Module that handles the request authentication and proxying upstream.
--
-- @module proxy
-- @author mikz
-- @license Apache License Version 2.0
local env = require 'resty.env'
local custom_config = env.get('APICAST_CUSTOM_CONFIG')
local resty_lrucache = require('resty.lrucache')
local backend_cache_handler = require('apicast.backend.cache_handler')
local Usage = require('apicast.usage')
local errors = require('apicast.errors')
local Upstream = require('apicast.upstream')
local escape = require("resty.http.uri_escape")
local cjson = require('cjson')
local assert = assert
local type = type
local insert = table.insert
local concat = table.concat
local gsub = string.gsub
local tonumber = tonumber
local setmetatable = setmetatable
local ipairs = ipairs
local encode_args = ngx.encode_args
local backend_client = require('apicast.backend_client')
local response_codes = env.enabled('APICAST_RESPONSE_CODES')
local reporting_executor = require('resty.concurrent.immediate_executor')
do
local reporting_threads = tonumber(env.value('APICAST_REPORTING_THREADS')) or 0
if reporting_threads > 0 then
reporting_executor = require('resty.concurrent.timer_pool_executor').new({
max_timers = reporting_threads,
fallback_policy = 'caller_runs',
})
ngx.log(ngx.WARN, 'using experimental asynchronous reporting threads: ', reporting_threads)
end
end
local _M = { }
local mt = {
__index = _M
}
function _M.shared_cache()
return ngx.shared.api_keys or resty_lrucache.new(1)
end
function _M.new(configuration)
local cache = _M.shared_cache() or error('missing cache store')
if not cache then
ngx.log(ngx.WARN, 'apicast cache error missing shared memory zone api_keys')
end
local cache_handler = backend_cache_handler.new(env.get('APICAST_BACKEND_CACHE_HANDLER'))
return setmetatable({
configuration = assert(configuration, 'missing proxy configuration'),
cache = cache,
cache_handler = cache_handler,
http_ng_backend = nil,
-- Params to send in 3scale backend calls that are not the typical ones
-- (credentials, usage, etc.).
-- This allows us, for example, to send a referrer.
extra_params_backend_authrep = {}
}, mt)
end
local function debug_header_enabled(service)
local debug_header_value = ngx.var.http_x_3scale_debug
return debug_header_value and debug_header_value == service.backend_authentication.value
end
local function output_debug_headers(service, usage, credentials)
ngx.log(ngx.INFO, 'usage: ', usage, ' credentials: ', credentials)
if debug_header_enabled(service) then
ngx.header["X-3scale-matched-rules"] = ngx.ctx.matched_patterns
ngx.header["X-3scale-credentials"] = credentials
ngx.header["X-3scale-usage"] = usage
ngx.header["X-3scale-hostname"] = ngx.var.hostname
ngx.header["X-3scale-service-id"] = service.id
ngx.header["X-3scale-service-name"] = service.serializable.system_name
end
end
local function matched_patterns(matched_rules)
local patterns = {}
for _, rule in ipairs(matched_rules) do
insert(patterns, rule.pattern)
end
return patterns
end
local function build_backend_client(self, service)
return assert(backend_client:new(service, self.http_ng_backend), 'missing backend')
end
function _M:authorize(context, service, usage, credentials, ttl)
if not usage or not credentials then return nil, 'missing usage or credentials' end
local formatted_usage = usage:format()
local encoded_usage = usage:encoded_format()
if encoded_usage == '' then
return errors.no_match(service)
end
local encoded_credentials = encode_args(credentials)
output_debug_headers(service, encoded_usage, encoded_credentials)
-- NYI: return to lower frame
local cached_key = ngx.var.cached_key .. ":" .. encoded_usage
local encoded_extra_params = encode_args(self.extra_params_backend_authrep)
if encoded_extra_params ~= '' then
cached_key = cached_key .. ":" .. encoded_extra_params
end
local cache = self.cache
local is_known = cache:get(cached_key)
if is_known == 200 and context.cache_is_disabled ~= true then
ngx.log(ngx.DEBUG, 'apicast cache hit key: ', cached_key)
ngx.var.cached_key = cached_key
else
ngx.log(ngx.INFO, 'apicast cache miss key: ', cached_key, ' value: ', is_known)
-- set cached_key to nil to avoid doing the authrep in post_action
ngx.var.cached_key = nil
local backend = build_backend_client(self, service)
local res = backend:authrep(formatted_usage, credentials, self.extra_params_backend_authrep)
local authorized, rejection_reason, retry_after = self:handle_backend_response(
context, cached_key, res, ttl
)
if not authorized then
if rejection_reason == 'limits_exceeded' then
return errors.limits_exceeded(service, retry_after)
else -- Generic error for now. Maybe return different ones in the future.
return errors.authorization_failed(service)
end
end
end
end
function _M.set_service(service)
if not service then
return errors.service_not_found()
end
ngx.ctx.service = service
ngx.var.service_id = service.id
return service
end
function _M.get_upstream(service, context)
service = service or ngx.ctx.service
if not service then
return errors.service_not_found()
end
-- Due to API as a product, the api_backend is no longer needed because this
-- can be handled by routing policy
local api_backend = service.api_backend
if not api_backend or api_backend == cjson.null or api_backend == '' then
return nil, nil
end
local upstream, err = Upstream.new(service.api_backend)
if not upstream then
return nil, err
end
if context and context.upstream_location_name then
upstream.location_name = context.upstream_location_name
end
upstream:use_host_header(service.hostname_rewrite)
return upstream
end
local function handle_oauth(service)
local oauth, err = service:oauth()
if oauth then
ngx.log(ngx.DEBUG, 'using OAuth: ', oauth)
if err then
ngx.log(ngx.WARN, 'failed to initialize ', oauth, ' for service ', service.id, ': ', err)
end
end
if oauth and oauth.call then
local f, params = oauth:call(service)
if f then
ngx.log(ngx.DEBUG, 'OAuth matched route')
return f(params) -- not really about the return value but showing something will call ngx.exit
end
end
return oauth
end
function _M:rewrite(service, context)
service = _M.set_service(service or ngx.ctx.service)
-- handle_oauth can terminate the request
self.oauth = handle_oauth(service)
ngx.var.secret_token = service.secret_token
-- Another policy might have already extracted the creds.
local credentials = context.extracted_credentials
local err
if not credentials then
credentials, err = service:extract_credentials()
end
if not credentials then
ngx.log(ngx.WARN, "cannot get credentials: ", err or 'unknown error')
return errors.no_credentials(service)
end
-- URI need to be escaped to be able to match values with special characters
-- (like spaces), request_uri is the original one, but rewrite_uri can modify
-- the value and mapping rule will not match.
-- Example: if URI is `/foo /bar` it will be translated to `/foo%20/bar`
local target_uri = escape.escape_uri(ngx.var.uri)
local usage, matched_rules = service:get_usage(ngx.req.get_method(), target_uri)
local cached_key = { service.id }
-- remove integer keys for serialization
-- as ngx.encode_args can't serialize integer keys
-- and verify all the keys exist
for i=1,#credentials do
local val = credentials[i]
if not val then
return errors.no_credentials(service)
else
credentials[i] = nil
end
insert(cached_key, val)
end
local ctx = ngx.ctx
local var = ngx.var
-- save those tables in context so they can be used in the backend client
context.usage = context.usage or Usage.new()
context.usage:merge(usage)
ctx.usage = context.usage
ctx.matched_rules = matched_rules
ctx.credentials = credentials
var.cached_key = concat(cached_key, ':')
if debug_header_enabled(service) then
local patterns = matched_patterns(matched_rules)
ctx.matched_patterns = concat(patterns, ', ')
end
local ttl
if self.oauth then
local jwt_payload
credentials, ttl, jwt_payload, err = self.oauth:transform_credentials(credentials, service.id)
if err then
ngx.log(ngx.DEBUG, 'oauth failed with ', err)
return errors.authorization_failed(service)
end
ctx.credentials = credentials
ctx.ttl = ttl
context.jwt = jwt_payload
end
context.credentials = ctx.credentials
end
function _M:access(context)
local ctx = ngx.ctx
local final_usage = context.usage
-- If routing policy changes the upstream and it only belongs to a specified
-- owner, we need to filter out the usage for APIs that are not used at all.
if context.route_upstream_usage_cleanup then
context:route_upstream_usage_cleanup(final_usage, ctx.matched_rules)
end
return self:authorize(context, context.service, final_usage, context.credentials, context.ttl)
end
local function response_codes_data(status)
if not response_codes then
return {}
else
return { ["log[code]"] = status }
end
end
local function post_action(self, context, cached_key, service, credentials, formatted_usage, response_status_code)
local backend = build_backend_client(self, service)
local res = backend:authrep(
formatted_usage,
credentials,
response_codes_data(response_status_code),
self.extra_params_backend_authrep
)
self:handle_backend_response(context, cached_key, res)
end
function _M:post_action(context)
local cached_key = ngx.var.cached_key
if not cached_key or cached_key == "null" or cached_key == '' then
ngx.log(ngx.INFO, '[async] skipping after action, no cached key')
return
end
ngx.log(ngx.INFO, '[async] reporting to backend asynchronously, cached_key: ', cached_key)
local service_id = ngx.var.service_id
local service = ngx.ctx.service or self.configuration:find_by_id(service_id)
local credentials = context.credentials
local formatted_usage = context.usage:format()
reporting_executor:post(post_action, self, context, cached_key, service, credentials, formatted_usage, ngx.var.status)
end
-- Returns the rejection reason from the headers of a 3scale backend response.
-- The header is set only when the authrep call to backend enables the option
-- to get the rejection reason. This is specified in the '3scale-options'
-- header of the request.
local function rejection_reason(response_headers)
return response_headers and response_headers['3scale-rejection-reason']
end
-- Returns the '3scale-limit-reset' from the headers of a 3scale backend
-- response.
-- This header is set only when enabled via the '3scale-options' header of the
-- request.
local function limit_reset(response_headers)
return response_headers and response_headers['3scale-limit-reset']
end
-- Returns the '3scale-limit-max-value' from the headers of a 3scale backend
-- response.
-- This header is set only when enabled via the '3scale-options' header of the
-- request.
local function limit_max_value(response_headers)
local max = response_headers and response_headers['3scale-limit-max-value']
return tonumber(max)
end
local function backend_is_unavailable(response_status)
-- 499 is a non-standard error returned by NGINX (client closed request)
return not response_status or response_status == 0 or response_status >= 499
end
function _M:handle_backend_response(context, cached_key, response, ttl)
ngx.log(ngx.DEBUG, '[backend] response status: ', response.status, ' body: ', response.body)
context:publish_backend_auth(response)
self.cache_handler(self.cache, cached_key, response, ttl)
if backend_is_unavailable(response.status) then
return self.cache:get(cached_key) == 200
end
local authorized = (response.status == 200)
local unauthorized_reason = not authorized and rejection_reason(response.headers)
local retry_after = not authorized and limit_reset(response.headers)
local limit_max = limit_max_value(response.headers)
-- This is for disabled metrics. Those have a limit of 0 in the 3scale
-- backend. When authorizing a disabled metric, backend returns "limits
-- exceeded" as the unauthorized reason. However, from the point of view of
-- APIcast, we want to distinguish between limits exceeded vs disabled
-- metric. That's why we reset the reason. The generic auth fail (403) will
-- be returned.
if limit_max == 0 and unauthorized_reason == 'limits_exceeded' then
unauthorized_reason = nil
end
return authorized, unauthorized_reason, retry_after
end
if custom_config then
local path = package.path
local module = gsub(custom_config, '%.lua$', '') -- strip .lua from end of the file
package.path = package.path .. ';' .. './?.lua;'
local ok, c = pcall(function() return require(module) end)
if not ok then
local chunk, _ = loadfile(custom_config)
if chunk then
ok = true
c = chunk()
end
end
package.path = path
if ok then
if type(c) == 'table' and type(c.setup) == 'function' then
ngx.log(ngx.DEBUG, 'executing custom config ', custom_config)
c.setup(_M)
else
ngx.log(ngx.ERR, 'failed to load custom config ', custom_config, ' because it does not return table with function setup')
end
else
ngx.log(ngx.ERR, 'failed to load custom config ', custom_config, ' with ', c)
end
end
return _M