| 1 | -- Prosody IM |
| 2 | -- Copyright (C) 2008-2010 Matthew Wild |
| 3 | -- Copyright (C) 2008-2010 Waqas Hussain |
| 4 | -- |
| 5 | -- This project is MIT/X11 licensed. Please see the |
| 6 | -- COPYING file in the source package for more information. |
| 7 | -- |
| 8 | |
| 9 | module.host = "*" -- Global module |
| 10 | |
| 11 | local hosts = _G.hosts; |
| 12 | local lxp = require "lxp"; |
| 13 | local new_xmpp_stream = require "util.xmppstream".new; |
| 14 | local httpserver = require "net.httpserver"; |
| 15 | local sm = require "core.sessionmanager"; |
| 16 | local sm_destroy_session = sm.destroy_session; |
| 17 | local new_uuid = require "util.uuid".generate; |
| 18 | local fire_event = prosody.events.fire_event; |
| 19 | local core_process_stanza = core_process_stanza; |
| 20 | local st = require "util.stanza"; |
| 21 | local logger = require "util.logger"; |
| 22 | local log = logger.init("mod_bosh"); |
| 23 | local timer = require "util.timer"; |
| 24 | |
| 25 | local xmlns_streams = "http://etherx.jabber.org/streams"; |
| 26 | local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams"; |
| 27 | local xmlns_bosh = "http://jabber.org/protocol/httpbind"; -- (hard-coded into a literal in session.send) |
| 28 | |
| 29 | local stream_callbacks = { |
| 30 | stream_ns = xmlns_bosh, stream_tag = "body", default_ns = "jabber:client" }; |
| 31 | |
| 32 | local BOSH_DEFAULT_HOLD = tonumber(module:get_option("bosh_default_hold")) or 1; |
| 33 | local BOSH_DEFAULT_INACTIVITY = tonumber(module:get_option("bosh_max_inactivity")) or 60; |
| 34 | local BOSH_DEFAULT_POLLING = tonumber(module:get_option("bosh_max_polling")) or 5; |
| 35 | local BOSH_DEFAULT_REQUESTS = tonumber(module:get_option("bosh_max_requests")) or 2; |
| 36 | |
| 37 | local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure"); |
| 38 | |
| 39 | local default_headers = { ["Content-Type"] = "text/xml; charset=utf-8" }; |
| 40 | |
| 41 | local cross_domain = module:get_option("cross_domain_bosh"); |
| 42 | if cross_domain then |
| 43 | default_headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; |
| 44 | default_headers["Access-Control-Allow-Headers"] = "Content-Type"; |
| 45 | default_headers["Access-Control-Max-Age"] = "7200"; |
| 46 | |
| 47 | if cross_domain == true then |
| 48 | default_headers["Access-Control-Allow-Origin"] = "*"; |
| 49 | elseif type(cross_domain) == "table" then |
| 50 | cross_domain = table.concat(cross_domain, ", "); |
| 51 | end |
| 52 | if type(cross_domain) == "string" then |
| 53 | default_headers["Access-Control-Allow-Origin"] = cross_domain; |
| 54 | end |
| 55 | end |
| 56 | |
| 57 | local trusted_proxies = module:get_option_set("trusted_proxies", {"127.0.0.1"})._items; |
| 58 | |
| 59 | local function get_ip_from_request(request) |
| 60 | local ip = request.handler:ip(); |
| 61 | local forwarded_for = request.headers["x-forwarded-for"]; |
| 62 | if forwarded_for then |
| 63 | forwarded_for = forwarded_for..", "..ip; |
| 64 | for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do |
| 65 | if not trusted_proxies[forwarded_ip] then |
| 66 | ip = forwarded_ip; |
| 67 | end |
| 68 | end |
| 69 | end |
| 70 | return ip; |
| 71 | end |
| 72 | |
| 73 | local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat; |
| 74 | local os_time = os.time; |
| 75 | |
| 76 | local sessions = {}; |
| 77 | local inactive_sessions = {}; -- Sessions which have no open requests |
| 78 | |
| 79 | -- Used to respond to idle sessions (those with waiting requests) |
| 80 | local waiting_requests = {}; |
| 81 | function on_destroy_request(request) |
| 82 | waiting_requests[request] = nil; |
| 83 | local session = sessions[request.sid]; |
| 84 | if session then |
| 85 | local requests = session.requests; |
| 86 | for i,r in ipairs(requests) do |
| 87 | if r == request then |
| 88 | t_remove(requests, i); |
| 89 | break; |
| 90 | end |
| 91 | end |
| 92 | |
| 93 | -- If this session now has no requests open, mark it as inactive |
| 94 | if #requests == 0 and session.bosh_max_inactive and not inactive_sessions[session] then |
| 95 | inactive_sessions[session] = os_time(); |
| 96 | (session.log or log)("debug", "BOSH session marked as inactive at %d", inactive_sessions[session]); |
| 97 | end |
| 98 | end |
| 99 | end |
| 100 | |
| 101 | function handle_request(method, body, request) |
| 102 | if (not body) or request.method ~= "POST" then |
| 103 | if request.method == "OPTIONS" then |
| 104 | local headers = {}; |
| 105 | for k,v in pairs(default_headers) do headers[k] = v; end |
| 106 | headers["Content-Type"] = nil; |
| 107 | return { headers = headers, body = "" }; |
| 108 | else |
| 109 | return "<html><body>You really don't look like a BOSH client to me... what do you want?</body></html>"; |
| 110 | end |
| 111 | end |
| 112 | if not method then |
| 113 | log("debug", "Request %s suffered error %s", tostring(request.id), body); |
| 114 | return; |
| 115 | end |
| 116 | --log("debug", "Handling new request %s: %s\n----------", request.id, tostring(body)); |
| 117 | request.notopen = true; |
| 118 | request.log = log; |
| 119 | request.on_destroy = on_destroy_request; |
| 120 | |
| 121 | local stream = new_xmpp_stream(request, stream_callbacks); |
| 122 | -- stream:feed() calls the stream_callbacks, so all stanzas in |
| 123 | -- the body are processed in this next line before it returns. |
| 124 | stream:feed(body); |
| 125 | |
| 126 | local session = sessions[request.sid]; |
| 127 | if session then |
| 128 | local r = session.requests; |
| 129 | log("debug", "Session %s has %d out of %d requests open", request.sid, #r, session.bosh_hold); |
| 130 | log("debug", "and there are %d things in the send_buffer", #session.send_buffer); |
| 131 | if #r > session.bosh_hold then |
| 132 | -- We are holding too many requests, send what's in the buffer, |
| 133 | log("debug", "We are holding too many requests, so..."); |
| 134 | if #session.send_buffer > 0 then |
| 135 | log("debug", "...sending what is in the buffer") |
| 136 | session.send(t_concat(session.send_buffer)); |
| 137 | session.send_buffer = {}; |
| 138 | else |
| 139 | -- or an empty response |
| 140 | log("debug", "...sending an empty response"); |
| 141 | session.send(""); |
| 142 | end |
| 143 | elseif #session.send_buffer > 0 then |
| 144 | log("debug", "Session has data in the send buffer, will send now.."); |
| 145 | local resp = t_concat(session.send_buffer); |
| 146 | session.send_buffer = {}; |
| 147 | session.send(resp); |
| 148 | end |
| 149 | |
| 150 | if not request.destroyed then |
| 151 | -- We're keeping this request open, to respond later |
| 152 | log("debug", "Have nothing to say, so leaving request unanswered for now"); |
| 153 | if session.bosh_wait then |
| 154 | request.reply_before = os_time() + session.bosh_wait; |
| 155 | waiting_requests[request] = true; |
| 156 | end |
| 157 | if inactive_sessions[session] then |
| 158 | -- Session was marked as inactive, since we have |
| 159 | -- a request open now, unmark it |
| 160 | inactive_sessions[session] = nil; |
| 161 | end |
| 162 | end |
| 163 | |
| 164 | return true; -- Inform httpserver we shall reply later |
| 165 | end |
| 166 | end |
| 167 | |
| 168 | |
| 169 | local function bosh_reset_stream(session) session.notopen = true; end |
| 170 | |
| 171 | local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" }; |
| 172 | |
| 173 | local function bosh_close_stream(session, reason) |
| 174 | (session.log or log)("info", "BOSH client disconnected"); |
| 175 | |
| 176 | local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", |
| 177 | ["xmlns:streams"] = xmlns_streams }); |
| 178 | |
| 179 | |
| 180 | if reason then |
| 181 | close_reply.attr.condition = "remote-stream-error"; |
| 182 | if type(reason) == "string" then -- assume stream error |
| 183 | close_reply:tag("stream:error") |
| 184 | :tag(reason, {xmlns = xmlns_xmpp_streams}); |
| 185 | elseif type(reason) == "table" then |
| 186 | if reason.condition then |
| 187 | close_reply:tag("stream:error") |
| 188 | :tag(reason.condition, stream_xmlns_attr):up(); |
| 189 | if reason.text then |
| 190 | close_reply:tag("text", stream_xmlns_attr):text(reason.text):up(); |
| 191 | end |
| 192 | if reason.extra then |
| 193 | close_reply:add_child(reason.extra); |
| 194 | end |
| 195 | elseif reason.name then -- a stanza |
| 196 | close_reply = reason; |
| 197 | end |
| 198 | end |
| 199 | log("info", "Disconnecting client, <stream:error> is: %s", tostring(close_reply)); |
| 200 | end |
| 201 | |
| 202 | local session_close_response = { headers = default_headers, body = tostring(close_reply) }; |
| 203 | |
| 204 | --FIXME: Quite sure we shouldn't reply to all requests with the error |
| 205 | for _, held_request in ipairs(session.requests) do |
| 206 | held_request:send(session_close_response); |
| 207 | held_request:destroy(); |
| 208 | end |
| 209 | sessions[session.sid] = nil; |
| 210 | sm_destroy_session(session); |
| 211 | end |
| 212 | |
| 213 | function stream_callbacks.streamopened(request, attr) |
| 214 | log("debug", "BOSH body open (sid: %s)", attr.sid); |
| 215 | local sid = attr.sid |
| 216 | if not sid then |
| 217 | -- New session request |
| 218 | request.notopen = nil; -- Signals that we accept this opening tag |
| 219 | |
| 220 | -- TODO: Sanity checks here (rid, to, known host, etc.) |
| 221 | if not hosts[attr.to] then |
| 222 | -- Unknown host |
| 223 | log("debug", "BOSH client tried to connect to unknown host: %s", tostring(attr.to)); |
| 224 | local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", |
| 225 | ["xmlns:streams"] = xmlns_streams, condition = "host-unknown" }); |
| 226 | request:send(tostring(close_reply)); |
| 227 | return; |
| 228 | end |
| 229 | |
| 230 | -- New session |
| 231 | sid = new_uuid(); |
| 232 | local session = { |
| 233 | type = "c2s_unauthed", conn = {}, sid = sid, rid = tonumber(attr.rid), host = attr.to, |
| 234 | bosh_version = attr.ver, bosh_wait = attr.wait, streamid = sid, |
| 235 | bosh_hold = BOSH_DEFAULT_HOLD, bosh_max_inactive = BOSH_DEFAULT_INACTIVITY, |
| 236 | requests = { }, send_buffer = {}, reset_stream = bosh_reset_stream, |
| 237 | close = bosh_close_stream, dispatch_stanza = core_process_stanza, |
| 238 | log = logger.init("bosh"..sid), secure = consider_bosh_secure or request.secure, |
| 239 | ip = get_ip_from_request(request); |
| 240 | }; |
| 241 | sessions[sid] = session; |
| 242 | |
| 243 | session.log("debug", "BOSH session created for request from %s", session.ip); |
| 244 | log("info", "New BOSH session, assigned it sid '%s'", sid); |
| 245 | local r, send_buffer = session.requests, session.send_buffer; |
| 246 | local response = { headers = default_headers } |
| 247 | function session.send(s) |
| 248 | -- We need to ensure that outgoing stanzas have the jabber:client xmlns |
| 249 | if s.attr and not s.attr.xmlns then |
| 250 | s = st.clone(s); |
| 251 | s.attr.xmlns = "jabber:client"; |
| 252 | end |
| 253 | --log("debug", "Sending BOSH data: %s", tostring(s)); |
| 254 | local oldest_request = r[1]; |
| 255 | if oldest_request then |
| 256 | log("debug", "We have an open request, so sending on that"); |
| 257 | response.body = t_concat{"<body xmlns='http://jabber.org/protocol/httpbind' sid='", sid, "' xmlns:stream = 'http://etherx.jabber.org/streams'>", tostring(s), "</body>" }; |
| 258 | oldest_request:send(response); |
| 259 | --log("debug", "Sent"); |
| 260 | if oldest_request.stayopen then |
| 261 | if #r>1 then |
| 262 | -- Move front request to back |
| 263 | t_insert(r, oldest_request); |
| 264 | t_remove(r, 1); |
| 265 | end |
| 266 | else |
| 267 | log("debug", "Destroying the request now..."); |
| 268 | oldest_request:destroy(); |
| 269 | end |
| 270 | elseif s ~= "" then |
| 271 | log("debug", "Saved to send buffer because there are %d open requests", #r); |
| 272 | -- Hmm, no requests are open :( |
| 273 | t_insert(session.send_buffer, tostring(s)); |
| 274 | log("debug", "There are now %d things in the send_buffer", #session.send_buffer); |
| 275 | end |
| 276 | end |
| 277 | |
| 278 | -- Send creation response |
| 279 | |
| 280 | local features = st.stanza("stream:features"); |
| 281 | hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); |
| 282 | fire_event("stream-features", session, features); |
| 283 | --xmpp:version='1.0' xmlns:xmpp='urn:xmpp:xbosh' |
| 284 | local response = st.stanza("body", { xmlns = xmlns_bosh, |
| 285 | wait = attr.wait, |
| 286 | inactivity = tostring(BOSH_DEFAULT_INACTIVITY), |
| 287 | polling = tostring(BOSH_DEFAULT_POLLING), |
| 288 | requests = tostring(BOSH_DEFAULT_REQUESTS), |
| 289 | hold = tostring(session.bosh_hold), |
| 290 | sid = sid, authid = sid, |
| 291 | ver = '1.6', from = session.host, |
| 292 | secure = 'true', ["xmpp:version"] = "1.0", |
| 293 | ["xmlns:xmpp"] = "urn:xmpp:xbosh", |
| 294 | ["xmlns:stream"] = "http://etherx.jabber.org/streams" |
| 295 | }):add_child(features); |
| 296 | request:send{ headers = default_headers, body = tostring(response) }; |
| 297 | |
| 298 | request.sid = sid; |
| 299 | return; |
| 300 | end |
| 301 | |
| 302 | local session = sessions[sid]; |
| 303 | if not session then |
| 304 | -- Unknown sid |
| 305 | log("info", "Client tried to use sid '%s' which we don't know about", sid); |
| 306 | request:send{ headers = default_headers, body = tostring(st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", condition = "item-not-found" })) }; |
| 307 | request.notopen = nil; |
| 308 | return; |
| 309 | end |
| 310 | |
| 311 | if session.rid then |
| 312 | local rid = tonumber(attr.rid); |
| 313 | local diff = rid - session.rid; |
| 314 | if diff > 1 then |
| 315 | session.log("warn", "rid too large (means a request was lost). Last rid: %d New rid: %s", session.rid, attr.rid); |
| 316 | elseif diff <= 0 then |
| 317 | -- Repeated, ignore |
| 318 | session.log("debug", "rid repeated (on request %s), ignoring: %s (diff %d)", request.id, session.rid, diff); |
| 319 | request.notopen = nil; |
| 320 | request.ignore = true; |
| 321 | request.sid = sid; |
| 322 | t_insert(session.requests, request); |
| 323 | return; |
| 324 | end |
| 325 | session.rid = rid; |
| 326 | end |
| 327 | |
| 328 | if attr.type == "terminate" then |
| 329 | -- Client wants to end this session |
| 330 | session:close(); |
| 331 | request.notopen = nil; |
| 332 | return; |
| 333 | end |
| 334 | |
| 335 | if session.notopen then |
| 336 | local features = st.stanza("stream:features"); |
| 337 | hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); |
| 338 | fire_event("stream-features", session, features); |
| 339 | session.send(features); |
| 340 | session.notopen = nil; |
| 341 | end |
| 342 | |
| 343 | request.notopen = nil; -- Signals that we accept this opening tag |
| 344 | t_insert(session.requests, request); |
| 345 | request.sid = sid; |
| 346 | end |
| 347 | |
| 348 | function stream_callbacks.handlestanza(request, stanza) |
| 349 | if request.ignore then return; end |
| 350 | log("debug", "BOSH stanza received: %s\n", stanza:top_tag()); |
| 351 | local session = sessions[request.sid]; |
| 352 | if session then |
| 353 | if stanza.attr.xmlns == xmlns_bosh then |
| 354 | stanza.attr.xmlns = nil; |
| 355 | end |
| 356 | core_process_stanza(session, stanza); |
| 357 | end |
| 358 | end |
| 359 | |
| 360 | function stream_callbacks.error(request, error) |
| 361 | log("debug", "Error parsing BOSH request payload; %s", error); |
| 362 | if not request.sid then |
| 363 | request:send({ headers = default_headers, status = "400 Bad Request" }); |
| 364 | return; |
| 365 | end |
| 366 | |
| 367 | local session = sessions[request.sid]; |
| 368 | if error == "stream-error" then -- Remote stream error, we close normally |
| 369 | session:close(); |
| 370 | else |
| 371 | session:close({ condition = "bad-format", text = "Error processing stream" }); |
| 372 | end |
| 373 | end |
| 374 | |
| 375 | local dead_sessions = {}; |
| 376 | function on_timer() |
| 377 | -- log("debug", "Checking for requests soon to timeout..."); |
| 378 | -- Identify requests timing out within the next few seconds |
| 379 | local now = os_time() + 3; |
| 380 | for request in pairs(waiting_requests) do |
| 381 | if request.reply_before <= now then |
| 382 | log("debug", "%s was soon to timeout, sending empty response", request.id); |
| 383 | -- Send empty response to let the |
| 384 | -- client know we're still here |
| 385 | if request.conn then |
| 386 | sessions[request.sid].send(""); |
| 387 | end |
| 388 | end |
| 389 | end |
| 390 | |
| 391 | now = now - 3; |
| 392 | local n_dead_sessions = 0; |
| 393 | for session, inactive_since in pairs(inactive_sessions) do |
| 394 | if session.bosh_max_inactive then |
| 395 | if now - inactive_since > session.bosh_max_inactive then |
| 396 | (session.log or log)("debug", "BOSH client inactive too long, destroying session at %d", now); |
| 397 | sessions[session.sid] = nil; |
| 398 | inactive_sessions[session] = nil; |
| 399 | n_dead_sessions = n_dead_sessions + 1; |
| 400 | dead_sessions[n_dead_sessions] = session; |
| 401 | end |
| 402 | else |
| 403 | inactive_sessions[session] = nil; |
| 404 | end |
| 405 | end |
| 406 | |
| 407 | for i=1,n_dead_sessions do |
| 408 | local session = dead_sessions[i]; |
| 409 | dead_sessions[i] = nil; |
| 410 | sm_destroy_session(session, "BOSH client silent for over "..session.bosh_max_inactive.." seconds"); |
| 411 | end |
| 412 | return 1; |
| 413 | end |
| 414 | |
| 415 | |
| 416 | local function setup() |
| 417 | local ports = module:get_option("bosh_ports") or { 5280 }; |
| 418 | httpserver.new_from_config(ports, handle_request, { base = "http-bind" }); |
| 419 | timer.add_task(1, on_timer); |
| 420 | end |
| 421 | if prosody.start_time then -- already started |
| 422 | setup(); |
| 423 | else |
| 424 | prosody.events.add_handler("server-started", setup); |
| 425 | end |