diff options
Diffstat (limited to 'plugins')
-rw-r--r-- | plugins/mod_admin_telnet.lua | 108 | ||||
-rw-r--r-- | plugins/mod_bosh.lua | 30 | ||||
-rw-r--r-- | plugins/mod_dialback.lua | 14 | ||||
-rw-r--r-- | plugins/mod_pubsub.lua | 364 | ||||
-rw-r--r-- | plugins/mod_saslauth.lua | 132 |
5 files changed, 634 insertions, 14 deletions
diff --git a/plugins/mod_admin_telnet.lua b/plugins/mod_admin_telnet.lua index 712e9eb7..da40f57e 100644 --- a/plugins/mod_admin_telnet.lua +++ b/plugins/mod_admin_telnet.lua @@ -19,6 +19,7 @@ local console_listener = { default_port = 5582; default_mode = "*l"; default_int require "util.iterators"; local jid_bare = require "util.jid".bare; local set, array = require "util.set", require "util.array"; +local cert_verify_identity = require "util.x509".verify_identity; local commands = {}; local def_env = {}; @@ -498,7 +499,7 @@ function def_env.s2s:show(match_jid) for remotehost, session in pairs(host_session.s2sout) do if (not match_jid) or remotehost:match(match_jid) or host:match(match_jid) then count_out = count_out + 1; - print(" "..host.." -> "..remotehost..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or "")); + print(" "..host.." -> "..remotehost..(session.cert_identity_status == "valid" and " (secure)" or "")..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or "")); if session.sendq then print(" There are "..#session.sendq.." queued outgoing stanzas for this connection"); end @@ -535,7 +536,7 @@ function def_env.s2s:show(match_jid) -- Pft! is what I say to list comprehensions or (session.hosts and #array.collect(keys(session.hosts)):filter(subhost_filter)>0)) then count_in = count_in + 1; - print(" "..host.." <- "..(session.from_host or "(unknown)")..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or "")); + print(" "..host.." <- "..(session.from_host or "(unknown)")..(session.cert_identity_status == "valid" and " (secure)" or "")..(session.secure and " (encrypted)" or "")..(session.compressed and " (compressed)" or "")); if session.type == "s2sin_unauthed" then print(" Connection not yet authenticated"); end @@ -561,6 +562,109 @@ function def_env.s2s:show(match_jid) return true, "Total: "..count_out.." outgoing, "..count_in.." incoming connections"; end +local function print_subject(print, subject) + for _, entry in ipairs(subject) do + print( + (" %s: %q"):format( + entry.name or entry.oid, + entry.value:gsub("[\r\n%z%c]", " ") + ) + ); + end +end + +function def_env.s2s:showcert(domain) + local ser = require "util.serialization".serialize; + local print = self.session.print; + local domain_sessions = set.new(array.collect(keys(incoming_s2s))) + /function(session) return session.from_host == domain; end; + for local_host in values(prosody.hosts) do + local s2sout = local_host.s2sout; + if s2sout and s2sout[domain] then + domain_sessions:add(s2sout[domain]); + end + end + local cert_set = {}; + for session in domain_sessions do + local conn = session.conn; + conn = conn and conn:socket(); + if not conn.getpeercertificate then + if conn.dohandshake then + error("This version of LuaSec does not support certificate viewing"); + end + else + local cert = conn:getpeercertificate(); + if cert then + local digest = cert:digest("sha1"); + if not cert_set[digest] then + local chain_valid, chain_err = conn:getpeerchainvalid(); + cert_set[digest] = { + { + from = session.from_host, + to = session.to_host, + direction = session.direction + }; + chain_valid = chain_valid; + chain_err = chain_err; + cert = cert; + }; + else + table.insert(cert_set[digest], { + from = session.from_host, + to = session.to_host, + direction = session.direction + }); + end + end + end + end + local domain_certs = array.collect(values(cert_set)); + -- Phew. We now have a array of unique certificates presented by domain. + local print = self.session.print; + local n_certs = #domain_certs; + + if n_certs == 0 then + return "No certificates found for "..domain; + end + + local function _capitalize_and_colon(byte) + return string.upper(byte)..":"; + end + local function pretty_fingerprint(hash) + return hash:gsub("..", _capitalize_and_colon):sub(1, -2); + end + + for cert_info in values(domain_certs) do + local cert = cert_info.cert; + print("---") + print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1"))); + print(""); + local n_streams = #cert_info; + print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":"); + for _, stream in ipairs(cert_info) do + if stream.direction == "incoming" then + print(" "..stream.to.." <- "..stream.from); + else + print(" "..stream.from.." -> "..stream.to); + end + end + print(""); + local chain_valid, err = cert_info.chain_valid, cert_info.chain_err; + local valid_identity = cert_verify_identity(domain, "xmpp-server", cert); + print("Trusted certificate: "..(chain_valid and "Yes" or ("No ("..err..")"))); + print("Issuer: "); + print_subject(print, cert:issuer()); + print(""); + print("Valid for "..domain..": "..(valid_identity and "Yes" or "No")); + print("Subject:"); + print_subject(print, cert:subject()); + end + print("---"); + return ("Showing "..n_certs.." certificate" + ..(n_certs==1 and "" or "s") + .." presented by "..domain.."."); +end + function def_env.s2s:close(from, to) local print, count = self.session.print, 0; diff --git a/plugins/mod_bosh.lua b/plugins/mod_bosh.lua index a747f3cb..c2c7eae9 100644 --- a/plugins/mod_bosh.lua +++ b/plugins/mod_bosh.lua @@ -162,7 +162,13 @@ function handle_request(method, body, request) end end - return true; -- Inform httpserver we shall reply later + if session.bosh_terminate then + session.log("debug", "Closing session with %d requests open", #session.requests); + session:close(); + return nil; + else + return true; -- Inform httpserver we shall reply later + end end end @@ -202,7 +208,6 @@ local function bosh_close_stream(session, reason) local session_close_response = { headers = default_headers, body = tostring(close_reply) }; - --FIXME: Quite sure we shouldn't reply to all requests with the error for _, held_request in ipairs(session.requests) do held_request:send(session_close_response); held_request:destroy(); @@ -255,7 +260,13 @@ function stream_callbacks.streamopened(request, attr) local oldest_request = r[1]; if oldest_request then log("debug", "We have an open request, so sending on that"); - response.body = t_concat{"<body xmlns='http://jabber.org/protocol/httpbind' sid='", sid, "' xmlns:stream = 'http://etherx.jabber.org/streams'>", tostring(s), "</body>" }; + response.body = t_concat({ + "<body xmlns='http://jabber.org/protocol/httpbind' ", + session.bosh_terminate and "type='terminate' " or "", + "sid='", sid, "' xmlns:stream = 'http://etherx.jabber.org/streams'>", + tostring(s), + "</body>" + }); oldest_request:send(response); --log("debug", "Sent"); if oldest_request.stayopen then @@ -327,13 +338,6 @@ function stream_callbacks.streamopened(request, attr) session.rid = rid; end - if attr.type == "terminate" then - -- Client wants to end this session - session:close(); - request.notopen = nil; - return; - end - if session.notopen then local features = st.stanza("stream:features"); hosts[session.host].events.fire_event("stream-features", { origin = session, features = features }); @@ -342,6 +346,12 @@ function stream_callbacks.streamopened(request, attr) session.notopen = nil; end + if attr.type == "terminate" then + -- Client wants to end this session, which we'll do + -- after processing any stanzas in this request + session.bosh_terminate = true; + end + request.notopen = nil; -- Signals that we accept this opening tag t_insert(session.requests, request); request.sid = sid; diff --git a/plugins/mod_dialback.lua b/plugins/mod_dialback.lua index 8c80dce6..c1c5d920 100644 --- a/plugins/mod_dialback.lua +++ b/plugins/mod_dialback.lua @@ -131,9 +131,19 @@ module:hook("stanza/jabber:server:dialback:result", function(event) end end); +module:hook_stanza("urn:ietf:params:xml:ns:xmpp-sasl", "failure", function (origin, stanza) + if origin.external_auth == "failed" then + module:log("debug", "SASL EXTERNAL failed, falling back to dialback"); + s2s_initiate_dialback(origin); + return true; + end +end, 100); + module:hook_stanza(xmlns_stream, "features", function (origin, stanza) - s2s_initiate_dialback(origin); - return true; + if not origin.external_auth or origin.external_auth == "failed" then + s2s_initiate_dialback(origin); + return true; + end end, 100); -- Offer dialback to incoming hosts diff --git a/plugins/mod_pubsub.lua b/plugins/mod_pubsub.lua new file mode 100644 index 00000000..fd307583 --- /dev/null +++ b/plugins/mod_pubsub.lua @@ -0,0 +1,364 @@ +local pubsub = require "util.pubsub"; +local st = require "util.stanza"; +local jid_bare = require "util.jid".bare; +local uuid_generate = require "util.uuid".generate; + +local xmlns_pubsub = "http://jabber.org/protocol/pubsub"; +local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors"; +local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event"; + +local autocreate_on_publish = module:get_option_boolean("autocreate_on_publish", false); +local autocreate_on_subscribe = module:get_option_boolean("autocreate_on_subscribe", false); +local pubsub_disco_name = module:get_option("name"); +if type(pubsub_disco_name) ~= "string" then pubsub_disco_name = "Prosody PubSub Service"; end + +local service; + +local handlers = {}; + +function handle_pubsub_iq(event) + local origin, stanza = event.origin, event.stanza; + local pubsub = stanza.tags[1]; + local action = pubsub.tags[1]; + local handler = handlers[stanza.attr.type.."_"..action.name]; + if handler then + handler(origin, stanza, action); + return true; + end +end + +local pubsub_errors = { + ["conflict"] = { "cancel", "conflict" }; + ["invalid-jid"] = { "modify", "bad-request", nil, "invalid-jid" }; + ["item-not-found"] = { "cancel", "item-not-found" }; + ["not-subscribed"] = { "modify", "unexpected-request", nil, "not-subscribed" }; + ["forbidden"] = { "cancel", "forbidden" }; +}; +function pubsub_error_reply(stanza, error) + local e = pubsub_errors[error]; + local reply = st.error_reply(stanza, unpack(e, 1, 3)); + if e[4] then + reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up(); + end + return reply; +end + +function handlers.get_items(origin, stanza, items) + local node = items.attr.node; + local item = items:get_child("item"); + local id = item and item.attr.id; + + local ok, results = service:get_items(node, stanza.attr.from, id); + if not ok then + return origin.send(pubsub_error_reply(stanza, results)); + end + + local data = st.stanza("items", { node = node }); + for _, entry in pairs(results) do + data:add_child(entry); + end + if data then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :add_child(data); + else + reply = pubsub_error_reply(stanza, "item-not-found"); + end + return origin.send(reply); +end + +function handlers.get_subscriptions(origin, stanza, subscriptions) + local node = subscriptions.attr.node; + local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from); + if not ok then + return origin.send(pubsub_error_reply(stanza, ret)); + end + local reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("subscriptions"); + for _, sub in ipairs(ret) do + reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up(); + end + return origin.send(reply); +end + +function handlers.set_create(origin, stanza, create) + local node = create.attr.node; + local ok, ret, reply; + if node then + ok, ret = service:create(node, stanza.attr.from); + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + else + repeat + node = uuid_generate(); + ok, ret = service:create(node, stanza.attr.from); + until ok or ret ~= "conflict"; + if ok then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("create", { node = node }); + else + reply = pubsub_error_reply(stanza, ret); + end + end + return origin.send(reply); +end + +function handlers.set_subscribe(origin, stanza, subscribe) + local node, jid = subscribe.attr.node, subscribe.attr.jid; + local ok, ret = service:add_subscription(node, stanza.attr.from, jid); + local reply; + if ok then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("subscription", { + node = node, + jid = jid, + subscription = "subscribed" + }); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_unsubscribe(origin, stanza, unsubscribe) + local node, jid = unsubscribe.attr.node, unsubscribe.attr.jid; + local ok, ret = service:remove_subscription(node, stanza.attr.from, jid); + local reply; + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_publish(origin, stanza, publish) + local node = publish.attr.node; + local item = publish:get_child("item"); + local id = (item and item.attr.id) or uuid_generate(); + local ok, ret = service:publish(node, stanza.attr.from, id, item); + local reply; + if ok then + reply = st.reply(stanza) + :tag("pubsub", { xmlns = xmlns_pubsub }) + :tag("publish", { node = node }) + :tag("item", { id = id }); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function handlers.set_retract(origin, stanza, retract) + local node, notify = retract.attr.node, retract.attr.notify; + notify = (notify == "1") or (notify == "true"); + local item = retract:get_child("item"); + local id = item and item.attr.id + local reply, notifier; + if notify then + notifier = st.stanza("retract", { id = id }); + end + local ok, ret = service:retract(node, stanza.attr.from, id, notifier); + if ok then + reply = st.reply(stanza); + else + reply = pubsub_error_reply(stanza, ret); + end + return origin.send(reply); +end + +function simple_broadcast(node, jids, item) + item = st.clone(item); + item.attr.xmlns = nil; -- Clear the pubsub namespace + local message = st.message({ from = module.host, type = "headline" }) + :tag("event", { xmlns = xmlns_pubsub_event }) + :tag("items", { node = node }) + :add_child(item); + for jid in pairs(jids) do + module:log("debug", "Sending notification to %s", jid); + message.attr.to = jid; + core_post_stanza(hosts[module.host], message); + end +end + +module:hook("iq/host/http://jabber.org/protocol/pubsub:pubsub", handle_pubsub_iq); + +local disco_info; + +local feature_map = { + create = { "create-nodes", autocreate_on_publish and "instant-nodes", "item-ids" }; + retract = { "delete-items", "retract-items" }; + publish = { "publish" }; + get_items = { "retrieve-items" }; + add_subscription = { "subscribe" }; + get_subscriptions = { "retrieve-subscriptions" }; +}; + +local function add_disco_features_from_service(disco, service) + for method, features in pairs(feature_map) do + if service[method] then + for _, feature in ipairs(features) do + if feature then + disco:tag("feature", { var = xmlns_pubsub.."#"..feature }):up(); + end + end + end + end + for affiliation in pairs(service.config.capabilities) do + if affiliation ~= "none" and affiliation ~= "owner" then + disco:tag("feature", { var = xmlns_pubsub.."#"..affiliation.."-affiliation" }):up(); + end + end +end + +local function build_disco_info(service) + local disco_info = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info" }) + :tag("identity", { category = "pubsub", type = "service", name = pubsub_disco_name }):up() + :tag("feature", { var = "http://jabber.org/protocol/pubsub" }):up(); + add_disco_features_from_service(disco_info, service); + return disco_info; +end + +module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function (event) + local origin, stanza = event.origin, event.stanza; + local node = stanza.tags[1].attr.node; + if not node then + return origin.send(st.reply(stanza):add_child(disco_info)); + else + local ok, ret = service:get_nodes(stanza.attr.from); + if ok and not ret[node] then + ok, ret = false, "item-not-found"; + end + if not ok then + return origin.send(pubsub_error_reply(stanza, ret)); + end + local reply = st.reply(stanza) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#info", node = node }) + :tag("identity", { category = "pubsub", type = "leaf" }); + return origin.send(reply); + end +end); + +local function handle_disco_items_on_node(event) + local stanza, origin = event.stanza, event.origin; + local query = stanza.tags[1]; + local node = query.attr.node; + local ok, ret = service:get_items(node, stanza.attr.from); + if not ok then + return origin.send(pubsub_error_reply(stanza, ret)); + end + + local reply = st.reply(stanza) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#items", node = node }); + + for id, item in pairs(ret) do + reply:tag("item", { jid = module.host, name = id }):up(); + end + + return origin.send(reply); +end + + +module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function (event) + if event.stanza.tags[1].attr.node then + return handle_disco_items_on_node(event); + end + local ok, ret = service:get_nodes(event.stanza.attr.from); + if not ok then + event.origin.send(pubsub_error_reply(stanza, ret)); + else + local reply = st.reply(event.stanza) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#items" }); + for node, node_obj in pairs(ret) do + reply:tag("item", { jid = module.host, node = node, name = node_obj.config.name }):up(); + end + event.origin.send(reply); + end + return true; +end); + +local admin_aff = module:get_option_string("default_admin_affiliation", "owner"); +local function get_affiliation(jid) + local bare_jid = jid_bare(jid); + if bare_jid == module.host or usermanager.is_admin(bare_jid, module.host) then + return admin_aff; + end +end + +function set_service(new_service) + service = new_service; + module.environment.service = service; + disco_info = build_disco_info(service); +end + +function module.save() + return { service = service }; +end + +function module.restore(data) + set_service(data.service); +end + +set_service(pubsub.new({ + capabilities = { + none = { + create = false; + publish = false; + retract = false; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + subscribe_other = false; + unsubscribe_other = false; + get_subscription_other = false; + get_subscriptions_other = false; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = false; + }; + owner = { + create = true; + publish = true; + retract = true; + get_nodes = true; + + subscribe = true; + unsubscribe = true; + get_subscription = true; + get_subscriptions = true; + get_items = true; + + + subscribe_other = true; + unsubscribe_other = true; + get_subscription_other = true; + get_subscriptions_other = true; + + be_subscribed = true; + be_unsubscribed = true; + + set_affiliation = true; + }; + }; + + autocreate_on_publish = autocreate_on_publish; + autocreate_on_subscribe = autocreate_on_subscribe; + + broadcaster = simple_broadcast; + get_affiliation = get_affiliation; + + normalize_jid = jid_bare; +}));
\ No newline at end of file diff --git a/plugins/mod_saslauth.lua b/plugins/mod_saslauth.lua index 4906d01f..1c0d0673 100644 --- a/plugins/mod_saslauth.lua +++ b/plugins/mod_saslauth.lua @@ -11,8 +11,11 @@ local st = require "util.stanza"; local sm_bind_resource = require "core.sessionmanager".bind_resource; local sm_make_authenticated = require "core.sessionmanager".make_authenticated; +local s2s_make_authenticated = require "core.s2smanager".make_authenticated; local base64 = require "util.encodings".base64; +local cert_verify_identity = require "util.x509".verify_identity; + local nodeprep = require "util.encodings".stringprep.nodeprep; local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler; local tostring = tostring; @@ -81,8 +84,123 @@ local function sasl_process_cdata(session, stanza) return true; end +module:hook_stanza(xmlns_sasl, "success", function (session, stanza) + if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end + module:log("debug", "SASL EXTERNAL with %s succeeded", session.to_host); + session.external_auth = "succeeded" + session:reset_stream(); + + local default_stream_attr = {xmlns = "jabber:server", ["xmlns:stream"] = "http://etherx.jabber.org/streams", + ["xmlns:db"] = 'jabber:server:dialback', version = "1.0", to = session.to_host, from = session.from_host}; + session.sends2s("<?xml version='1.0'?>"); + session.sends2s(st.stanza("stream:stream", default_stream_attr):top_tag()); + + s2s_make_authenticated(session, session.to_host); + return true; +end) + +module:hook_stanza(xmlns_sasl, "failure", function (session, stanza) + if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end + + module:log("info", "SASL EXTERNAL with %s failed", session.to_host) + -- TODO: Log the failure reason + session.external_auth = "failed" +end, 500) + +module:hook_stanza(xmlns_sasl, "failure", function (session, stanza) + -- TODO: Dialback wasn't loaded. Do something useful. +end, 90) + +module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) + if session.type ~= "s2sout_unauthed" or not session.secure then return; end + + local mechanisms = stanza:get_child("mechanisms", xmlns_sasl) + if mechanisms then + for mech in mechanisms:childtags() do + if mech[1] == "EXTERNAL" then + module:log("debug", "Initiating SASL EXTERNAL with %s", session.to_host); + local reply = st.stanza("auth", {xmlns = xmlns_sasl, mechanism = "EXTERNAL"}); + reply:text(base64.encode(session.from_host)) + session.sends2s(reply) + session.external_auth = "attempting" + return true + end + end + end +end, 150); + +local function s2s_external_auth(session, stanza) + local mechanism = stanza.attr.mechanism; + + if not session.secure then + if mechanism == "EXTERNAL" then + session.sends2s(build_reply("failure", "encryption-required")) + else + session.sends2s(build_reply("failure", "invalid-mechanism")) + end + return true; + end + + if mechanism ~= "EXTERNAL" or session.cert_chain_status ~= "valid" then + session.sends2s(build_reply("failure", "invalid-mechanism")) + return true; + end + + local text = stanza[1] + if not text then + session.sends2s(build_reply("failure", "malformed-request")) + return true + end + + -- Either the value is "=" and we've already verified the external + -- cert identity, or the value is a string and either matches the + -- from_host ( + + text = base64.decode(text) + if not text then + session.sends2s(build_reply("failure", "incorrect-encoding")) + return true; + end + + if session.cert_identity_status == "valid" then + if text ~= "" and text ~= session.from_host then + session.sends2s(build_reply("failure", "invalid-authzid")) + return true + end + else + if text == "" then + session.sends2s(build_reply("failure", "invalid-authzid")) + return true + end + + local cert = session.conn:socket():getpeercertificate() + if (cert_verify_identity(text, "xmpp-server", cert)) then + session.cert_identity_status = "valid" + else + session.cert_identity_status = "invalid" + session.sends2s(build_reply("failure", "invalid-authzid")) + return true + end + end + + session.external_auth = "succeeded" + + if not session.from_host then + session.from_host = text; + end + session.sends2s(build_reply("success")) + module:log("info", "Accepting SASL EXTERNAL identity from %s", text or session.from_host); + s2s_make_authenticated(session, text or session.from_host) + session:reset_stream(); + return true +end + module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function(event) local session, stanza = event.origin, event.stanza; + if session.type == "s2sin_unauthed" then + return s2s_external_auth(session, stanza) + end + if session.type ~= "c2s_unauthed" then return; end if session.sasl_handler and session.sasl_handler.selected then @@ -141,6 +259,20 @@ module:hook("stream-features", function(event) end end); +module:hook("s2s-stream-features", function(event) + local origin, features = event.origin, event.features; + if origin.secure and origin.type == "s2sin_unauthed" then + -- Offer EXTERNAL if chain is valid and either we didn't validate + -- the identity or it passed. + if origin.cert_chain_status == "valid" and origin.cert_identity_status ~= "invalid" then --TODO: Configurable + module:log("debug", "Offering SASL EXTERNAL") + features:tag("mechanisms", { xmlns = xmlns_sasl }) + :tag("mechanism"):text("EXTERNAL") + :up():up(); + end + end +end); + module:hook("iq/self/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event) local origin, stanza = event.origin, event.stanza; local resource; |