您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

mod_reservations.lua 23KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. --- This is a port of Jicofo's Reservation System as a prosody module
  2. -- ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
  3. --
  4. -- We try to retain the same behaviour and interfaces where possible, but there
  5. -- is some difference:
  6. -- * In the event that the DELETE call fails, Jicofo's reservation
  7. -- system retains reservation data and allows re-creation of room if requested by
  8. -- the same creator without making further call to the API; this module does not
  9. -- offer this behaviour. Re-creation of a closed room will behave like a new meeting
  10. -- and trigger a new API call to validate the reservation.
  11. -- * Jicofo's reservation system expect int-based conflict_id. We take any sensible string.
  12. --
  13. -- In broad strokes, this module works by intercepting Conference IQs sent to focus component
  14. -- and buffers it until reservation is confirmed (by calling the provided API endpoint).
  15. -- The IQ events are routed on to focus component if reservation is valid, or error
  16. -- response is sent back to the origin if reservation is denied. Events are routed as usual
  17. -- if the room already exists.
  18. --
  19. --
  20. -- Installation:
  21. -- =============
  22. --
  23. -- Under domain config,
  24. -- 1. add "reservations" to modules_enabled.
  25. -- 2. Specify URL base for your API endpoint using "reservations_api_prefix" (required)
  26. -- 3. Optional config:
  27. -- * set "reservations_api_timeout" to change API call timeouts (defaults to 20 seconds)
  28. -- * set "reservations_api_headers" to specify custom HTTP headers included in
  29. -- all API calls e.g. to provide auth tokens.
  30. -- * set "reservations_api_retry_count" to the number of times API call failures are retried (defaults to 3)
  31. -- * set "reservations_api_retry_delay" seconds to wait between retries (defaults to 3s)
  32. -- * set "reservations_api_should_retry_for_code" to a function that takes an HTTP response code and
  33. -- returns true if API call should be retried. By default, retries are done for 5XX
  34. -- responses. Timeouts are never retried, and HTTP call failures are always retried.
  35. --
  36. --
  37. -- Example config:
  38. --
  39. -- VirtualHost "jitmeet.example.com"
  40. -- -- ....
  41. -- modules_enabled = {
  42. -- -- ....
  43. -- "reservations";
  44. -- }
  45. -- reservations_api_prefix = "http://reservation.example.com"
  46. --
  47. -- --- The following are all optional
  48. -- reservations_api_headers = {
  49. -- ["Authorization"] = "Bearer TOKEN-237958623045";
  50. -- }
  51. -- reservations_api_timeout = 10 -- timeout if API does not respond within 10s
  52. -- reservations_api_retry_count = 5 -- retry up to 5 times
  53. -- reservations_api_retry_delay = 1 -- wait 1s between retries
  54. -- reservations_api_should_retry_for_code = function (code)
  55. -- return code >= 500 or code == 408
  56. -- end
  57. --
  58. local jid = require 'util.jid';
  59. local http = require "net.http";
  60. local json = require "util.json";
  61. local st = require "util.stanza";
  62. local timer = require 'util.timer';
  63. local datetime = require 'util.datetime';
  64. local get_room_from_jid = module:require "util".get_room_from_jid;
  65. local is_healthcheck_room = module:require "util".is_healthcheck_room;
  66. local room_jid_match_rewrite = module:require "util".room_jid_match_rewrite;
  67. local api_prefix = module:get_option("reservations_api_prefix");
  68. local api_headers = module:get_option("reservations_api_headers");
  69. local api_timeout = module:get_option("reservations_api_timeout", 20);
  70. local api_retry_count = tonumber(module:get_option("reservations_api_retry_count", 3));
  71. local api_retry_delay = tonumber(module:get_option("reservations_api_retry_delay", 3));
  72. -- Option for user to control HTTP response codes that will result in a retry.
  73. -- Defaults to returning true on any 5XX code or 0
  74. local api_should_retry_for_code = module:get_option("reservations_api_should_retry_for_code", function (code)
  75. return code >= 500;
  76. end)
  77. local muc_component_host = module:get_option_string("main_muc");
  78. -- How often to check and evict expired reservation data
  79. local expiry_check_period = 60;
  80. -- Cannot proceed if "reservations_api_prefix" not configured
  81. if not api_prefix then
  82. module:log("error", "reservations_api_prefix not specified. Disabling %s", module:get_name());
  83. return;
  84. end
  85. -- get/infer focus component hostname so we can intercept IQ bound for it
  86. local focus_component_host = module:get_option_string("focus_component");
  87. if not focus_component_host then
  88. local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
  89. if not muc_domain_base then
  90. module:log("error", "Could not infer focus domain. Disabling %s", module:get_name());
  91. return;
  92. end
  93. focus_component_host = 'focus.'..muc_domain_base;
  94. end
  95. -- common HTTP headers added to all API calls
  96. local http_headers = {
  97. ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")";
  98. };
  99. if api_headers then -- extra headers from config
  100. for key, value in pairs(api_headers) do
  101. http_headers[key] = value;
  102. end
  103. end
  104. --- Utils
  105. --- Converts int timestamp to datetime string compatible with Java SimpleDateFormat
  106. -- @param t timestamps in seconds. Supports int (as returned by os.time()) or higher
  107. -- precision (as returned by socket.gettime())
  108. -- @return formatted datetime string (yyyy-MM-dd'T'HH:mm:ss.SSSX)
  109. local function to_java_date_string(t)
  110. local t_secs, mantissa = math.modf(t);
  111. local ms_str = (mantissa == 0) and '.000' or tostring(mantissa):sub(2,5);
  112. local date_str = os.date("!%Y-%m-%dT%H:%M:%S", t_secs);
  113. return date_str..ms_str..'Z';
  114. end
  115. --- Start non-blocking HTTP call
  116. -- @param url URL to call
  117. -- @param options options table as expected by net.http where we provide optional headers, body or method.
  118. -- @param callback if provided, called with callback(response_body, response_code) when call complete.
  119. -- @param timeout_callback if provided, called without args when request times out.
  120. -- @param retries how many times to retry on failure; 0 means no retries.
  121. local function async_http_request(url, options, callback, timeout_callback, retries)
  122. local completed = false;
  123. local timed_out = false;
  124. local retries = retries or api_retry_count;
  125. local function cb_(response_body, response_code)
  126. if not timed_out then -- request completed before timeout
  127. completed = true;
  128. if (response_code == 0 or api_should_retry_for_code(response_code)) and retries > 0 then
  129. module:log("warn", "API Response code %d. Will retry after %ds", response_code, api_retry_delay);
  130. timer.add_task(api_retry_delay, function()
  131. async_http_request(url, options, callback, timeout_callback, retries - 1)
  132. end)
  133. return;
  134. end
  135. if callback then
  136. callback(response_body, response_code)
  137. end
  138. end
  139. end
  140. local request = http.request(url, options, cb_);
  141. timer.add_task(api_timeout, function ()
  142. timed_out = true;
  143. if not completed then
  144. http.destroy_request(request);
  145. if timeout_callback then
  146. timeout_callback()
  147. end
  148. end
  149. end);
  150. end
  151. --- Returns current timestamp
  152. local function now()
  153. -- Don't really need higher precision of socket.gettime(). Besides, we loose
  154. -- milliseconds precision when converting back to timestamp from date string
  155. -- when we use datetime.parse(t), so let's be consistent.
  156. return os.time();
  157. end
  158. --- Start RoomReservation implementation
  159. -- Status enums used in RoomReservation:meta.status
  160. local STATUS = {
  161. PENDING = 0;
  162. SUCCESS = 1;
  163. FAILED = -1;
  164. }
  165. local RoomReservation = {};
  166. RoomReservation.__index = RoomReservation;
  167. function newRoomReservation(room_jid, creator_jid)
  168. return setmetatable({
  169. room_jid = room_jid;
  170. -- Reservation metadata. store as table so we can set and read atomically.
  171. -- N.B. This should always be updated using self.set_status_*
  172. meta = {
  173. status = STATUS.PENDING;
  174. mail_owner = jid.bare(creator_jid);
  175. conflict_id = nil;
  176. start_time = now(); -- timestamp, in seconds
  177. expires_at = nil; -- timestamp, in seconds
  178. error_text = nil;
  179. error_code = nil;
  180. };
  181. -- Array of pending events that we need to route once API call is complete
  182. pending_events = {};
  183. -- Set true when API call trigger has been triggered (by enqueue of first event)
  184. api_call_triggered = false;
  185. }, RoomReservation);
  186. end
  187. --- Extracts room name from room jid
  188. function RoomReservation:get_room_name()
  189. return jid.node(self.room_jid);
  190. end
  191. --- Checks if reservation data is expires and should be evicted from store
  192. function RoomReservation:is_expired()
  193. return self.meta.expires_at ~= nil and now() > self.meta.expires_at;
  194. end
  195. --- Main entry point for handing and routing events.
  196. function RoomReservation:enqueue_or_route_event(event)
  197. if self.meta.status == STATUS.PENDING then
  198. table.insert(self.pending_events, event)
  199. if not self.api_call_triggered == true then
  200. self:call_api_create_conference();
  201. end
  202. else
  203. -- API call already complete. Immediately route without enqueueing.
  204. -- This could happen if request comes in between the time reservation approved
  205. -- and when Jicofo actually creates the room.
  206. module:log("debug", "Reservation details already stored. Skipping queue for %s", self.room_jid);
  207. self:route_event(event);
  208. end
  209. end
  210. --- Updates status and initiates event routing. Called internally when API call complete.
  211. function RoomReservation:set_status_success(start_time, duration, mail_owner, conflict_id)
  212. module:log("info", "Reservation created successfully for %s", self.room_jid);
  213. self.meta = {
  214. status = STATUS.SUCCESS;
  215. mail_owner = mail_owner or self.meta.mail_owner;
  216. conflict_id = conflict_id;
  217. start_time = start_time;
  218. expires_at = start_time + duration;
  219. error_text = nil;
  220. error_code = nil;
  221. }
  222. self:route_pending_events()
  223. end
  224. --- Updates status and initiates error response to pending events. Called internally when API call complete.
  225. function RoomReservation:set_status_failed(error_code, error_text)
  226. module:log("info", "Reservation creation failed for %s - (%s) %s", self.room_jid, error_code, error_text);
  227. self.meta = {
  228. status = STATUS.FAILED;
  229. mail_owner = self.meta.mail_owner;
  230. conflict_id = nil;
  231. start_time = self.meta.start_time;
  232. -- Retain reservation rejection for a short while so we have time to report failure to
  233. -- existing clients and not trigger a re-query too soon.
  234. -- N.B. Expiry could take longer since eviction happens periodically.
  235. expires_at = now() + 30;
  236. error_text = error_text;
  237. error_code = error_code;
  238. }
  239. self:route_pending_events()
  240. end
  241. --- Triggers routing of all enqueued events
  242. function RoomReservation:route_pending_events()
  243. if self.meta.status == STATUS.PENDING then -- should never be called while PENDING. check just in case.
  244. return;
  245. end
  246. module:log("debug", "Routing all pending events for %s", self.room_jid);
  247. local event;
  248. while #self.pending_events ~= 0 do
  249. event = table.remove(self.pending_events);
  250. self:route_event(event)
  251. end
  252. end
  253. --- Event routing implementation
  254. function RoomReservation:route_event(event)
  255. -- this should only be called after API call complete and status no longer PENDING
  256. assert(self.meta.status ~= STATUS.PENDING, "Attempting to route event while API call still PENDING")
  257. local meta = self.meta;
  258. local origin, stanza = event.origin, event.stanza;
  259. if meta.status == STATUS.FAILED then
  260. module:log("debug", "Route: Sending reservation error to %s", stanza.attr.from);
  261. self:reply_with_error(event, meta.error_code, meta.error_text);
  262. else
  263. if meta.status == STATUS.SUCCESS then
  264. if self:is_expired() then
  265. module:log("debug", "Route: Sending reservation expiry to %s", stanza.attr.from);
  266. self:reply_with_error(event, 419, "Reservation expired");
  267. else
  268. module:log("debug", "Route: Forwarding on event from %s", stanza.attr.from);
  269. prosody.core_post_stanza(origin, stanza, false); -- route iq to intended target (focus)
  270. end
  271. else
  272. -- this should never happen unless dev made a mistake. Block by default just in case.
  273. module:log("error", "Reservation for %s has invalid state %s. Rejecting request.", self.room_jid, meta.status);
  274. self:reply_with_error(event, 500, "Failed to determine reservation state");
  275. end
  276. end
  277. end
  278. --- Generates reservation-error stanza and sends to event origin.
  279. function RoomReservation:reply_with_error(event, error_code, error_text)
  280. local stanza = event.stanza;
  281. local id = stanza.attr.id;
  282. local to = stanza.attr.from;
  283. local from = stanza.attr.to;
  284. event.origin.send(
  285. st.iq({ type="error", to=to, from=from, id=id })
  286. :tag("error", { type="cancel" })
  287. :tag("service-unavailable", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):up()
  288. :tag("text", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):text(error_text):up()
  289. :tag("reservation-error", { xmlns="http://jitsi.org/protocol/focus", ["error-code"]=tostring(error_code) })
  290. );
  291. end
  292. --- Initiates non-blocking API call to validate reservation
  293. function RoomReservation:call_api_create_conference()
  294. self.api_call_triggered = true;
  295. local url = api_prefix..'/conference';
  296. local request_data = {
  297. name = self:get_room_name();
  298. start_time = to_java_date_string(self.meta.start_time);
  299. mail_owner = self.meta.mail_owner;
  300. }
  301. local http_options = {
  302. body = http.formencode(request_data); -- because Jicofo reservation encodes as form data instead JSON
  303. method = 'POST';
  304. headers = http_headers;
  305. }
  306. module:log("debug", "Sending POST /conference for %s", self.room_jid);
  307. async_http_request(url, http_options, function (response_body, response_code)
  308. self:on_api_create_conference_complete(response_body, response_code);
  309. end, function ()
  310. self:on_api_call_timeout();
  311. end);
  312. end
  313. --- Parses and validates HTTP response body for conference payload
  314. -- Ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
  315. -- @return nil if invalid, or table with keys "id", "name", "mail_owner", "start_time", "duration".
  316. function RoomReservation:parse_conference_response(response_body)
  317. local data = json.decode(response_body);
  318. if data == nil then -- invalid JSON payload
  319. module:log("error", "Invalid JSON response from API - %s", response_body);
  320. return;
  321. end
  322. if data.name == nil or data.name:lower() ~= self:get_room_name() then
  323. module:log("error", "Missing or mismathing room name - %s", data.name);
  324. return;
  325. end
  326. if data.id == nil then
  327. module:log("error", "Missing id");
  328. return;
  329. end
  330. if data.mail_owner == nil then
  331. module:log("error", "Missing mail_owner");
  332. return;
  333. end
  334. local duration = tonumber(data.duration);
  335. if duration == nil then
  336. module:log("error", "Missing or invalid duration - %s", data.duration);
  337. return;
  338. end
  339. data.duration = duration;
  340. local start_time = datetime.parse(data.start_time); -- N.B. we lose milliseconds portion of the date
  341. if start_time == nil then
  342. module:log("error", "Missing or invalid start_time - %s", data.start_time);
  343. return;
  344. end
  345. data.start_time = start_time;
  346. return data;
  347. end
  348. --- Parses and validates HTTP error response body for API call.
  349. -- Expect JSON with a "message" field.
  350. -- @return message string, or generic error message if invalid payload.
  351. function RoomReservation:parse_error_message_from_response(response_body)
  352. local data = json.decode(response_body);
  353. if data ~= nil and data.message ~= nil then
  354. module:log("debug", "Invalid error response body. Will use generic error message.");
  355. return data.message;
  356. else
  357. return "Rejected by reservation server";
  358. end
  359. end
  360. --- callback on API timeout
  361. function RoomReservation:on_api_call_timeout()
  362. self:set_status_failed(500, 'Reservation lookup timed out');
  363. end
  364. --- callback on API response
  365. function RoomReservation:on_api_create_conference_complete(response_body, response_code)
  366. if response_code == 200 or response_code == 201 then
  367. self:handler_conference_data_returned_from_api(response_body);
  368. elseif response_code == 409 then
  369. self:handle_conference_already_exist(response_body);
  370. elseif response_code == nil then -- warrants a retry, but this should be done automatically by the http call method.
  371. self:set_status_failed(500, 'Could not contact reservation server');
  372. else
  373. self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
  374. end
  375. end
  376. function RoomReservation:handler_conference_data_returned_from_api(response_body)
  377. local data = self:parse_conference_response(response_body);
  378. if not data then -- invalid response from API
  379. module:log("error", "API returned success code but invalid payload");
  380. self:set_status_failed(500, 'Invalid response from reservation server');
  381. else
  382. self:set_status_success(data.start_time, data.duration, data.mail_owner, data.id)
  383. end
  384. end
  385. function RoomReservation:handle_conference_already_exist(response_body)
  386. local data = json.decode(response_body);
  387. if data == nil or data.conflict_id == nil then
  388. -- yes, in the case of 409, API expected to return "id" as "conflict_id".
  389. self:set_status_failed(409, 'Invalid response from reservation server');
  390. else
  391. local url = api_prefix..'/conference/'..data.conflict_id;
  392. local http_options = {
  393. method = 'GET';
  394. headers = http_headers;
  395. }
  396. async_http_request(url, http_options, function(response_body, response_code)
  397. if response_code == 200 then
  398. self:handler_conference_data_returned_from_api(response_body);
  399. else
  400. self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
  401. end
  402. end, function ()
  403. self:on_api_call_timeout();
  404. end);
  405. end
  406. end
  407. --- End RoomReservation
  408. --- Store reservations lookups that are still pending or with room still active
  409. local reservations = {}
  410. local function get_or_create_reservations(room_jid, creator_jid)
  411. if reservations[room_jid] == nil then
  412. module:log("debug", "Creating new reservation data for %s", room_jid);
  413. reservations[room_jid] = newRoomReservation(room_jid, creator_jid);
  414. end
  415. return reservations[room_jid];
  416. end
  417. local function evict_expired_reservations()
  418. local expired = {}
  419. -- first, gather jids of expired rooms. So we don't remove from table while iterating.
  420. for room_jid, res in pairs(reservations) do
  421. if res:is_expired() then
  422. table.insert(expired, room_jid);
  423. end
  424. end
  425. local room;
  426. for _, room_jid in ipairs(expired) do
  427. room = get_room_from_jid(room_jid);
  428. if room then
  429. -- Close room if still active (reservation duration exceeded)
  430. module:log("info", "Room exceeded reservation duration. Terminating %s", room_jid);
  431. room:destroy(nil, "Scheduled conference duration exceeded.");
  432. -- Rely on room_destroyed to calls DELETE /conference and drops reservation[room_jid]
  433. else
  434. module:log("error", "Reservation references expired room that is no longer active. Dropping %s", room_jid);
  435. -- This should not happen unless evict_expired_reservations somehow gets triggered
  436. -- between the time room is destroyed and room_destroyed callback is called. (Possible?)
  437. -- But just in case, we drop the reservation to avoid repeating this path on every pass.
  438. reservations[room_jid] = nil;
  439. end
  440. end
  441. end
  442. timer.add_task(expiry_check_period, function()
  443. evict_expired_reservations();
  444. return expiry_check_period;
  445. end)
  446. --- Intercept conference IQ to Jicofo handle reservation checks before allowing normal event flow
  447. module:log("info", "Hook to global pre-iq/host");
  448. module:hook("pre-iq/host", function(event)
  449. local stanza = event.stanza;
  450. if stanza.name ~= "iq" or stanza.attr.to ~= focus_component_host or stanza.attr.type ~= 'set' then
  451. return; -- not IQ for jicofo. Ignore this event.
  452. end
  453. local conference = stanza:get_child('conference', 'http://jitsi.org/protocol/focus');
  454. if conference == nil then
  455. return; -- not Conference IQ. Ignore.
  456. end
  457. local room_jid = room_jid_match_rewrite(conference.attr.room);
  458. if get_room_from_jid(room_jid) ~= nil then
  459. module:log("debug", "Skip reservation check for existing room %s", room_jid);
  460. return; -- room already exists. Continue with normal flow
  461. end
  462. local res = get_or_create_reservations(room_jid, stanza.attr.from);
  463. res:enqueue_or_route_event(event); -- hand over to reservation obj to route event
  464. return true;
  465. end);
  466. --- Forget reservation details once room destroyed so query is repeated if room re-created
  467. local function room_destroyed(event)
  468. local res;
  469. local room = event.room
  470. if not is_healthcheck_room(room.jid) then
  471. res = reservations[room.jid]
  472. -- drop reservation data for this room
  473. reservations[room.jid] = nil
  474. if res then -- just in case event triggered more than once?
  475. module:log("info", "Dropped reservation data for destroyed room %s", room.jid);
  476. local conflict_id = res.meta.conflict_id
  477. if conflict_id then
  478. local url = api_prefix..'/conference/'..conflict_id;
  479. local http_options = {
  480. method = 'DELETE';
  481. headers = http_headers;
  482. }
  483. module:log("debug", "Sending DELETE /conference/%s", conflict_id);
  484. async_http_request(url, http_options);
  485. end
  486. end
  487. end
  488. end
  489. function process_host(host)
  490. if host == muc_component_host then -- the conference muc component
  491. module:log("info", "Hook to muc events on %s", host);
  492. module:context(host):hook("muc-room-destroyed", room_destroyed, -1);
  493. end
  494. end
  495. if prosody.hosts[muc_component_host] == nil then
  496. module:log("info", "No muc component found, will listen for it: %s", muc_component_host)
  497. prosody.events.add_handler("host-activated", process_host);
  498. else
  499. process_host(muc_component_host);
  500. end