-
Notifications
You must be signed in to change notification settings - Fork 6.7k
/
mod_reservations.lua
589 lines (498 loc) · 22.5 KB
/
mod_reservations.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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
--- This is a port of Jicofo's Reservation System as a prosody module
-- ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
--
-- We try to retain the same behaviour and interfaces where possible, but there
-- is some difference:
-- * In the event that the DELETE call fails, Jicofo's reservation
-- system retains reservation data and allows re-creation of room if requested by
-- the same creator without making further call to the API; this module does not
-- offer this behaviour. Re-creation of a closed room will behave like a new meeting
-- and trigger a new API call to validate the reservation.
-- * Jicofo's reservation system expect int-based conflict_id. We take any sensible string.
--
-- In broad strokes, this module works by intercepting Conference IQs sent to focus component
-- and buffers it until reservation is confirmed (by calling the provided API endpoint).
-- The IQ events are routed on to focus component if reservation is valid, or error
-- response is sent back to the origin if reservation is denied. Events are routed as usual
-- if the room already exists.
--
--
-- Installation:
-- =============
--
-- Under domain config,
-- 1. add "reservations" to modules_enabled.
-- 2. Specify URL base for your API endpoint using "reservations_api_prefix" (required)
-- 3. Optional config:
-- * set "reservations_api_timeout" to change API call timeouts (defaults to 20 seconds)
-- * set "reservations_api_headers" to specify custom HTTP headers included in
-- all API calls e.g. to provide auth tokens.
-- * set "reservations_api_retry_count" to the number of times API call failures are retried (defaults to 3)
-- * set "reservations_api_retry_delay" seconds to wait between retries (defaults to 3s)
-- * set "reservations_api_should_retry_for_code" to a function that takes an HTTP response code and
-- returns true if API call should be retried. By default, retries are done for 5XX
-- responses. Timeouts are never retried, and HTTP call failures are always retried.
--
--
-- Example config:
--
-- VirtualHost "jitmeet.example.com"
-- modules_enabled = {
-- "reservations";
-- }
-- reservations_api_prefix = "http://reservation.example.com"
--
-- --- The following are all optional
-- reservations_api_headers = {
-- ["Authorization"] = "Bearer TOKEN-237958623045";
-- }
-- reservations_api_timeout = 10 -- timeout if API does not respond within 10s
-- reservations_api_retry_count = 5 -- retry up to 5 times
-- reservations_api_retry_delay = 1 -- wait 1s between retries
-- reservations_api_should_retry_for_code = function (code)
-- return code >= 500 or code == 408
-- end
--
local jid = require 'util.jid';
local http = require "net.http";
local json = require "util.json";
local st = require "util.stanza";
local timer = require 'util.timer';
local datetime = require 'util.datetime';
local get_room_from_jid = module:require "util".get_room_from_jid;
local is_healthcheck_room = module:require "util".is_healthcheck_room;
local room_jid_match_rewrite = module:require "util".room_jid_match_rewrite;
local api_prefix = module:get_option("reservations_api_prefix");
local api_headers = module:get_option("reservations_api_headers");
local api_timeout = module:get_option("reservations_api_timeout", 20);
local api_retry_count = tonumber(module:get_option("reservations_api_retry_count", 3));
local api_retry_delay = tonumber(module:get_option("reservations_api_retry_delay", 3));
-- Option for user to control HTTP response codes that will result in a retry.
-- Defaults to returning true on any 5XX code or 0
local api_should_retry_for_code = module:get_option("reservations_api_should_retry_for_code", function (code)
return code >= 500;
end)
local muc_component_host = module:get_option_string("main_muc");
-- How often to check and evict expired reservation data
local expiry_check_period = 60;
-- Cannot proceed if "reservations_api_prefix" not configured
if not api_prefix then
module:log("error", "reservations_api_prefix not specified. Disabling %s", module:get_name());
return;
end
-- get/infer focus component hostname so we can intercept IQ bound for it
local focus_component_host = module:get_option_string("focus_component");
if not focus_component_host then
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
if not muc_domain_base then
module:log("error", "Could not infer focus domain. Disabling %s", module:get_name());
return;
end
focus_component_host = 'focus.'..muc_domain_base;
end
-- common HTTP headers added to all API calls
local http_headers = {
["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")";
};
if api_headers then -- extra headers from config
for key, value in pairs(api_headers) do
http_headers[key] = value;
end
end
--- Utils
--- Converts int timestamp to datetime string compatible with Java SimpleDateFormat
-- @param t timestamps in seconds. Supports int (as returned by os.time()) or higher
-- precision (as returned by socket.gettime())
-- @return formatted datetime string (yyyy-MM-dd'T'HH:mm:ss.SSSX)
local function to_java_date_string(t)
local t_secs, mantissa = math.modf(t);
local ms_str = (mantissa == 0) and '.000' or tostring(mantissa):sub(2,5);
local date_str = os.date("!%Y-%m-%dT%H:%M:%S", t_secs);
return date_str..ms_str..'Z';
end
--- Start non-blocking HTTP call
-- @param url URL to call
-- @param options options table as expected by net.http where we provide optional headers, body or method.
-- @param callback if provided, called with callback(response_body, response_code) when call complete.
-- @param timeout_callback if provided, called without args when request times out.
-- @param retries how many times to retry on failure; 0 means no retries.
local function async_http_request(url, options, callback, timeout_callback, retries)
local completed = false;
local timed_out = false;
local retries = retries or api_retry_count;
local function cb_(response_body, response_code)
if not timed_out then -- request completed before timeout
completed = true;
if (response_code == 0 or api_should_retry_for_code(response_code)) and retries > 0 then
module:log("warn", "API Response code %d. Will retry after %ds", response_code, api_retry_delay);
timer.add_task(api_retry_delay, function()
async_http_request(url, options, callback, timeout_callback, retries - 1)
end)
return;
end
if callback then
callback(response_body, response_code)
end
end
end
local request = http.request(url, options, cb_);
timer.add_task(api_timeout, function ()
timed_out = true;
if not completed then
http.destroy_request(request);
if timeout_callback then
timeout_callback()
end
end
end);
end
--- Returns current timestamp
local function now()
-- Don't really need higher precision of socket.gettime(). Besides, we loose
-- milliseconds precision when converting back to timestamp from date string
-- when we use datetime.parse(t), so let's be consistent.
return os.time();
end
--- Start RoomReservation implementation
-- Status enums used in RoomReservation:meta.status
local STATUS = {
PENDING = 0;
SUCCESS = 1;
FAILED = -1;
}
local RoomReservation = {};
RoomReservation.__index = RoomReservation;
function newRoomReservation(room_jid, creator_jid)
return setmetatable({
room_jid = room_jid;
-- Reservation metadata. store as table so we can set and read atomically.
-- N.B. This should always be updated using self.set_status_*
meta = {
status = STATUS.PENDING;
mail_owner = jid.bare(creator_jid);
conflict_id = nil;
start_time = now(); -- timestamp, in seconds
expires_at = nil; -- timestamp, in seconds
error_text = nil;
error_code = nil;
};
-- Array of pending events that we need to route once API call is complete
pending_events = {};
-- Set true when API call trigger has been triggered (by enqueue of first event)
api_call_triggered = false;
}, RoomReservation);
end
--- Extracts room name from room jid
function RoomReservation:get_room_name()
return jid.node(self.room_jid);
end
--- Checks if reservation data is expires and should be evicted from store
function RoomReservation:is_expired()
return self.meta.expires_at ~= nil and now() > self.meta.expires_at;
end
--- Main entry point for handing and routing events.
function RoomReservation:enqueue_or_route_event(event)
if self.meta.status == STATUS.PENDING then
table.insert(self.pending_events, event)
if not self.api_call_triggered == true then
self:call_api_create_conference();
end
else
-- API call already complete. Immediately route without enqueueing.
-- This could happen if request comes in between the time reservation approved
-- and when Jicofo actually creates the room.
module:log("debug", "Reservation details already stored. Skipping queue for %s", self.room_jid);
self:route_event(event);
end
end
--- Updates status and initiates event routing. Called internally when API call complete.
function RoomReservation:set_status_success(start_time, duration, mail_owner, conflict_id)
module:log("info", "Reservation created successfully for %s", self.room_jid);
self.meta = {
status = STATUS.SUCCESS;
mail_owner = mail_owner or self.meta.mail_owner;
conflict_id = conflict_id;
start_time = start_time;
expires_at = start_time + duration;
error_text = nil;
error_code = nil;
}
self:route_pending_events()
end
--- Updates status and initiates error response to pending events. Called internally when API call complete.
function RoomReservation:set_status_failed(error_code, error_text)
module:log("info", "Reservation creation failed for %s - (%s) %s", self.room_jid, error_code, error_text);
self.meta = {
status = STATUS.FAILED;
mail_owner = self.meta.mail_owner;
conflict_id = nil;
start_time = self.meta.start_time;
-- Retain reservation rejection for a short while so we have time to report failure to
-- existing clients and not trigger a re-query too soon.
-- N.B. Expiry could take longer since eviction happens periodically.
expires_at = now() + 30;
error_text = error_text;
error_code = error_code;
}
self:route_pending_events()
end
--- Triggers routing of all enqueued events
function RoomReservation:route_pending_events()
if self.meta.status == STATUS.PENDING then -- should never be called while PENDING. check just in case.
return;
end
module:log("debug", "Routing all pending events for %s", self.room_jid);
local event;
while #self.pending_events ~= 0 do
event = table.remove(self.pending_events);
self:route_event(event)
end
end
--- Event routing implementation
function RoomReservation:route_event(event)
-- this should only be called after API call complete and status no longer PENDING
assert(self.meta.status ~= STATUS.PENDING, "Attempting to route event while API call still PENDING")
local meta = self.meta;
local origin, stanza = event.origin, event.stanza;
if meta.status == STATUS.FAILED then
module:log("debug", "Route: Sending reservation error to %s", stanza.attr.from);
self:reply_with_error(event, meta.error_code, meta.error_text);
else
if meta.status == STATUS.SUCCESS then
if self:is_expired() then
module:log("debug", "Route: Sending reservation expiry to %s", stanza.attr.from);
self:reply_with_error(event, 419, "Reservation expired");
else
module:log("debug", "Route: Forwarding on event from %s", stanza.attr.from);
prosody.core_post_stanza(origin, stanza, false); -- route iq to intended target (focus)
end
else
-- this should never happen unless dev made a mistake. Block by default just in case.
module:log("error", "Reservation for %s has invalid state %s. Rejecting request.", self.room_jid, meta.status);
self:reply_with_error(event, 500, "Failed to determine reservation state");
end
end
end
--- Generates reservation-error stanza and sends to event origin.
function RoomReservation:reply_with_error(event, error_code, error_text)
local stanza = event.stanza;
local id = stanza.attr.id;
local to = stanza.attr.from;
local from = stanza.attr.to;
event.origin.send(
st.iq({ type="error", to=to, from=from, id=id })
:tag("error", { type="cancel" })
:tag("service-unavailable", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):up()
:tag("text", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):text(error_text):up()
:tag("reservation-error", { xmlns="http://jitsi.org/protocol/focus", ["error-code"]=tostring(error_code) })
);
end
--- Initiates non-blocking API call to validate reservation
function RoomReservation:call_api_create_conference()
self.api_call_triggered = true;
local url = api_prefix..'/conference';
local request_data = {
name = self:get_room_name();
start_time = to_java_date_string(self.meta.start_time);
mail_owner = self.meta.mail_owner;
}
local http_options = {
body = http.formencode(request_data); -- because Jicofo reservation encodes as form data instead JSON
method = 'POST';
headers = http_headers;
}
module:log("debug", "Sending POST /conference for %s", self.room_jid);
async_http_request(url, http_options, function (response_body, response_code)
self:on_api_create_conference_complete(response_body, response_code);
end, function ()
self:on_api_call_timeout();
end);
end
--- Parses and validates HTTP response body for conference payload
-- Ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
-- @return nil if invalid, or table with keys "id", "name", "mail_owner", "start_time", "duration".
function RoomReservation:parse_conference_response(response_body)
local data = json.decode(response_body);
if data == nil then -- invalid JSON payload
module:log("error", "Invalid JSON response from API - %s", response_body);
return;
end
if data.name == nil or data.name:lower() ~= self:get_room_name() then
module:log("error", "Missing or mismathing room name - %s", data.name);
return;
end
if data.id == nil then
module:log("error", "Missing id");
return;
end
if data.mail_owner == nil then
module:log("error", "Missing mail_owner");
return;
end
local duration = tonumber(data.duration);
if duration == nil then
module:log("error", "Missing or invalid duration - %s", data.duration);
return;
end
data.duration = duration;
local start_time = datetime.parse(data.start_time); -- N.B. we lose milliseconds portion of the date
if start_time == nil then
module:log("error", "Missing or invalid start_time - %s", data.start_time);
return;
end
data.start_time = start_time;
return data;
end
--- Parses and validates HTTP error response body for API call.
-- Expect JSON with a "message" field.
-- @return message string, or generic error message if invalid payload.
function RoomReservation:parse_error_message_from_response(response_body)
local data = json.decode(response_body);
if data ~= nil and data.message ~= nil then
module:log("debug", "Invalid error response body. Will use generic error message.");
return data.message;
else
return "Rejected by reservation server";
end
end
--- callback on API timeout
function RoomReservation:on_api_call_timeout()
self:set_status_failed(500, 'Reservation lookup timed out');
end
--- callback on API response
function RoomReservation:on_api_create_conference_complete(response_body, response_code)
if response_code == 200 or response_code == 201 then
self:handler_conference_data_returned_from_api(response_body);
elseif response_code == 409 then
self:handle_conference_already_exist(response_body);
elseif response_code == nil then -- warrants a retry, but this should be done automatically by the http call method.
self:set_status_failed(500, 'Could not contact reservation server');
else
self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
end
end
function RoomReservation:handler_conference_data_returned_from_api(response_body)
local data = self:parse_conference_response(response_body);
if not data then -- invalid response from API
module:log("error", "API returned success code but invalid payload");
self:set_status_failed(500, 'Invalid response from reservation server');
else
self:set_status_success(data.start_time, data.duration, data.mail_owner, data.id)
end
end
function RoomReservation:handle_conference_already_exist(response_body)
local data = json.decode(response_body);
if data == nil or data.conflict_id == nil then
-- yes, in the case of 409, API expected to return "id" as "conflict_id".
self:set_status_failed(409, 'Invalid response from reservation server');
else
local url = api_prefix..'/conference/'..data.conflict_id;
local http_options = {
method = 'GET';
headers = http_headers;
}
async_http_request(url, http_options, function(response_body, response_code)
if response_code == 200 then
self:handler_conference_data_returned_from_api(response_body);
else
self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
end
end, function ()
self:on_api_call_timeout();
end);
end
end
--- End RoomReservation
--- Store reservations lookups that are still pending or with room still active
local reservations = {}
local function get_or_create_reservations(room_jid, creator_jid)
if reservations[room_jid] == nil then
module:log("debug", "Creating new reservation data for %s", room_jid);
reservations[room_jid] = newRoomReservation(room_jid, creator_jid);
end
return reservations[room_jid];
end
local function evict_expired_reservations()
local expired = {}
-- first, gather jids of expired rooms. So we don't remove from table while iterating.
for room_jid, res in pairs(reservations) do
if res:is_expired() then
table.insert(expired, room_jid);
end
end
local room;
for _, room_jid in ipairs(expired) do
room = get_room_from_jid(room_jid);
if room then
-- Close room if still active (reservation duration exceeded)
module:log("info", "Room exceeded reservation duration. Terminating %s", room_jid);
room:destroy(nil, "Scheduled conference duration exceeded.");
-- Rely on room_destroyed to calls DELETE /conference and drops reservation[room_jid]
else
module:log("error", "Reservation references expired room that is no longer active. Dropping %s", room_jid);
-- This should not happen unless evict_expired_reservations somehow gets triggered
-- between the time room is destroyed and room_destroyed callback is called. (Possible?)
-- But just in case, we drop the reservation to avoid repeating this path on every pass.
reservations[room_jid] = nil;
end
end
end
timer.add_task(expiry_check_period, function()
evict_expired_reservations();
return expiry_check_period;
end)
--- Intercept conference IQ to Jicofo handle reservation checks before allowing normal event flow
module:log("info", "Hook to global pre-iq/host");
module:hook("pre-iq/host", function(event)
local stanza = event.stanza;
if stanza.name ~= "iq" or stanza.attr.to ~= focus_component_host or stanza.attr.type ~= 'set' then
return; -- not IQ for jicofo. Ignore this event.
end
local conference = stanza:get_child('conference', 'http://jitsi.org/protocol/focus');
if conference == nil then
return; -- not Conference IQ. Ignore.
end
local room_jid = room_jid_match_rewrite(conference.attr.room);
if get_room_from_jid(room_jid) ~= nil then
module:log("debug", "Skip reservation check for existing room %s", room_jid);
return; -- room already exists. Continue with normal flow
end
local res = get_or_create_reservations(room_jid, stanza.attr.from);
res:enqueue_or_route_event(event); -- hand over to reservation obj to route event
return true;
end);
--- Forget reservation details once room destroyed so query is repeated if room re-created
local function room_destroyed(event)
local res;
local room = event.room
if not is_healthcheck_room(room.jid) then
res = reservations[room.jid]
-- drop reservation data for this room
reservations[room.jid] = nil
if res then -- just in case event triggered more than once?
module:log("info", "Dropped reservation data for destroyed room %s", room.jid);
local conflict_id = res.meta.conflict_id
if conflict_id then
local url = api_prefix..'/conference/'..conflict_id;
local http_options = {
method = 'DELETE';
headers = http_headers;
}
module:log("debug", "Sending DELETE /conference/%s", conflict_id);
async_http_request(url, http_options);
end
end
end
end
function process_host(host)
if host == muc_component_host then -- the conference muc component
module:log("info", "Hook to muc events on %s", host);
module:context(host):hook("muc-room-destroyed", room_destroyed, -1);
end
end
if prosody.hosts[muc_component_host] == nil then
module:log("info", "No muc component found, will listen for it: %s", muc_component_host)
prosody.events.add_handler("host-activated", process_host);
else
process_host(muc_component_host);
end