diff --git a/.github/workflows/ci-lua.yml b/.github/workflows/ci-lua.yml index 6fe4c595747c..45e89a9698e4 100644 --- a/.github/workflows/ci-lua.yml +++ b/.github/workflows/ci-lua.yml @@ -17,7 +17,8 @@ jobs: - name: Check lua codes run: | - set -o pipefail && luacheck . | awk -F: ' + set -o pipefail && luacheck . \ + --exclude-files=resources/prosody-plugins/mod_firewall/mod_firewall.lua | awk -F: ' { print $0 printf "::warning file=%s,line=%s,col=%s::%s\n", $1, $2, $3, $4 diff --git a/resources/prosody-plugins/mod_debug_traceback.lua b/resources/prosody-plugins/mod_debug_traceback.lua new file mode 100644 index 000000000000..5c021b59303f --- /dev/null +++ b/resources/prosody-plugins/mod_debug_traceback.lua @@ -0,0 +1,54 @@ +module:set_global(); + +local traceback = require "util.debug".traceback; +local pposix = require "util.pposix"; +local os_date = os.date; +local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, { + yyyymmdd = function (t) + return os_date("%Y%m%d", t); + end; + hhmmss = function (t) + return os_date("%H%M%S", t); + end; +}); + +local count = 0; + +local function get_filename(filename_template) + filename_template = filename_template; + return render_filename(filename_template, { + paths = prosody.paths; + pid = pposix.getpid(); + count = count; + time = os.time(); + }); +end + +local default_filename_template = "{paths.data}/traceback-{pid}-{count}.log"; +local filename_template = module:get_option_string("debug_traceback_filename", default_filename_template); +local signal_name = module:get_option_string("debug_traceback_signal", "SIGUSR1"); + +function dump_traceback() + module:log("info", "Received %s, writing traceback", signal_name); + + local tb = traceback(); + module:fire_event("debug_traceback/triggered", { traceback = tb }); + + local f, err = io.open(get_filename(filename_template), "a+"); + if not f then + module:log("error", "Unable to write traceback: %s", err); + return; + end + f:write("-- Traceback generated at ", os.date("%b %d %H:%M:%S"), " --\n"); + f:write(tb, "\n"); + f:write("-- End of traceback --\n"); + f:close(); + count = count + 1; +end + +local mod_posix = module:depends("posix"); +if rawget(mod_posix, "features") and mod_posix.features.signal_events then + module:hook("signal/"..signal_name, dump_traceback); +else + require"util.signal".signal(signal_name, dump_traceback); +end diff --git a/resources/prosody-plugins/mod_firewall/actions.lib.lua b/resources/prosody-plugins/mod_firewall/actions.lib.lua new file mode 100644 index 000000000000..97ac873791d4 --- /dev/null +++ b/resources/prosody-plugins/mod_firewall/actions.lib.lua @@ -0,0 +1,280 @@ +local unpack = table.unpack or unpack; + +local interpolation = require "util.interpolation"; +local template = interpolation.new("%b$$", function (s) return ("%q"):format(s) end); + +--luacheck: globals meta idsafe +local action_handlers = {}; + + +-- Takes an XML string and returns a code string that builds that stanza +-- using st.stanza() +local function compile_xml(data) + local code = {}; + local first, short_close = true, nil; + for tagline, text in data:gmatch("<([^>]+)>([^<]*)") do + if tagline:sub(-1,-1) == "/" then + tagline = tagline:sub(1, -2); + short_close = true; + end + if tagline:sub(1,1) == "/" then + code[#code+1] = (":up()"); + else + local name, attr = tagline:match("^(%S*)%s*(.*)$"); + local attr_str = {}; + for k, _, v in attr:gmatch("(%S+)=([\"'])([^%2]-)%2") do + if #attr_str == 0 then + table.insert(attr_str, ", { "); + else + table.insert(attr_str, ", "); + end + if k:find("^%a%w*$") then + table.insert(attr_str, string.format("%s = %q", k, v)); + else + table.insert(attr_str, string.format("[%q] = %q", k, v)); + end + end + if #attr_str > 0 then + table.insert(attr_str, " }"); + end + if first then + code[#code+1] = (string.format("st.stanza(%q %s)", name, #attr_str>0 and table.concat(attr_str) or ", nil")); + first = nil; + else + code[#code+1] = (string.format(":tag(%q%s)", name, table.concat(attr_str))); + end + end + if text and text:find("%S") then + code[#code+1] = (string.format(":text(%q)", text)); + elseif short_close then + short_close = nil; + code[#code+1] = (":up()"); + end + end + return table.concat(code, ""); +end + +function action_handlers.PASS() + return "do return pass_return end" +end + +function action_handlers.DROP() + return "do return true end"; +end + +function action_handlers.DEFAULT() + return "do return false end"; +end + +function action_handlers.RETURN() + return "do return end" +end + +function action_handlers.STRIP(tag_desc) + local code = {}; + local name, xmlns = tag_desc:match("^(%S+) (.+)$"); + if not name then + name, xmlns = tag_desc, nil; + end + if name == "*" then + name = nil; + end + code[#code+1] = ("local stanza_xmlns = stanza.attr.xmlns; "); + code[#code+1] = "stanza:maptags(function (tag) if "; + if name then + code[#code+1] = ("tag.name == %q and "):format(name); + end + if xmlns then + code[#code+1] = ("(tag.attr.xmlns or stanza_xmlns) == %q "):format(xmlns); + else + code[#code+1] = ("tag.attr.xmlns == stanza_xmlns "); + end + code[#code+1] = "then return nil; end return tag; end );"; + return table.concat(code); +end + +function action_handlers.INJECT(tag) + return "stanza:add_child("..compile_xml(tag)..")", { "st" }; +end + +local error_types = { + ["bad-request"] = "modify"; + ["conflict"] = "cancel"; + ["feature-not-implemented"] = "cancel"; + ["forbidden"] = "auth"; + ["gone"] = "cancel"; + ["internal-server-error"] = "cancel"; + ["item-not-found"] = "cancel"; + ["jid-malformed"] = "modify"; + ["not-acceptable"] = "modify"; + ["not-allowed"] = "cancel"; + ["not-authorized"] = "auth"; + ["payment-required"] = "auth"; + ["policy-violation"] = "modify"; + ["recipient-unavailable"] = "wait"; + ["redirect"] = "modify"; + ["registration-required"] = "auth"; + ["remote-server-not-found"] = "cancel"; + ["remote-server-timeout"] = "wait"; + ["resource-constraint"] = "wait"; + ["service-unavailable"] = "cancel"; + ["subscription-required"] = "auth"; + ["undefined-condition"] = "cancel"; + ["unexpected-request"] = "wait"; +}; + + +local function route_modify(make_new, to, drop) + local reroute, deps = "session.send(newstanza)", { "st" }; + if to then + reroute = ("newstanza.attr.to = %q; core_post_stanza(session, newstanza)"):format(to); + deps[#deps+1] = "core_post_stanza"; + end + return ([[do local newstanza = st.%s; %s;%s end]]) + :format(make_new, reroute, drop and " return true" or ""), deps; +end + +function action_handlers.BOUNCE(with) + local error = with and with:match("^%S+") or "service-unavailable"; + local error_type = error:match(":(%S+)"); + if not error_type then + error_type = error_types[error] or "cancel"; + else + error = error:match("^[^:]+"); + end + error, error_type = string.format("%q", error), string.format("%q", error_type); + local text = with and with:match(" %((.+)%)$"); + if text then + text = string.format("%q", text); + else + text = "nil"; + end + local route_modify_code, deps = route_modify(("error_reply(stanza, %s, %s, %s)"):format(error_type, error, text), nil, true); + deps[#deps+1] = "type"; + deps[#deps+1] = "name"; + return [[if type == "error" or (name == "iq" and type == "result") then return true; end -- Don't reply to 'error' stanzas, or iq results + ]]..route_modify_code, deps; +end + +function action_handlers.REDIRECT(where) + return route_modify("clone(stanza)", where, true); +end + +function action_handlers.COPY(where) + return route_modify("clone(stanza)", where, false); +end + +function action_handlers.REPLY(with) + return route_modify(("reply(stanza):body(%q)"):format(with)); +end + +function action_handlers.FORWARD(where) + local code = [[ + local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" }); + local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza); + core_post_stanza(session, newstanza); + ]]; + return code:format(where), { "core_post_stanza", "current_host" }; +end + +function action_handlers.LOG(string) + local level = string:match("^%[(%a+)%]") or "info"; + string = string:gsub("^%[%a+%] ?", ""); + local meta_deps = {}; + local code = meta(("(session.log or log)(%q, '%%s', %q);"):format(level, string), meta_deps); + return code, meta_deps; +end + +function action_handlers.RULEDEP(dep) + return "", { dep }; +end + +function action_handlers.EVENT(name) + return ("fire_event(%q, event)"):format(name); +end + +function action_handlers.JUMP_EVENT(name) + return ("do return fire_event(%q, event); end"):format(name); +end + +function action_handlers.JUMP_CHAIN(name) + return template([[do + local ret = fire_event($chain_event$, event); + if ret ~= nil then + if ret == false then + log("debug", "Chain %q accepted stanza (ret %s)", $chain_name$, tostring(ret)); + return pass_return; + end + log("debug", "Chain %q rejected stanza (ret %s)", $chain_name$, tostring(ret)); + return ret; + end + end]], { chain_event = "firewall/chains/"..name, chain_name = name }); +end + +function action_handlers.MARK_ORIGIN(name) + return [[session.firewall_marked_]]..idsafe(name)..[[ = current_timestamp;]], { "timestamp" }; +end + +function action_handlers.UNMARK_ORIGIN(name) + return [[session.firewall_marked_]]..idsafe(name)..[[ = nil;]] +end + +function action_handlers.MARK_USER(name) + return ([[if session.username and session.host == current_host then + fire_event("firewall/marked/user", { + username = session.username; + mark = %q; + timestamp = current_timestamp; + }); + else + log("warn", "Attempt to MARK a remote user - only local users may be marked"); + end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)), { + "current_host"; + "timestamp"; + }; +end + +function action_handlers.UNMARK_USER(name) + return ([[if session.username and session.host == current_host then + fire_event("firewall/unmarked/user", { + username = session.username; + mark = %q; + }); + else + log("warn", "Attempt to UNMARK a remote user - only local users may be marked"); + end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)); +end + +function action_handlers.ADD_TO(spec) + local list_name, value = spec:match("(%S+) (.+)"); + local meta_deps = {}; + value = meta(("%q"):format(value), meta_deps); + return ("list_%s:add(%s);"):format(list_name, value), { "list:"..list_name, unpack(meta_deps) }; +end + +function action_handlers.UNSUBSCRIBE_SENDER() + return "rostermanager.unsubscribed(to_node, to_host, bare_from);\ + rostermanager.roster_push(to_node, to_host, bare_from);\ + core_post_stanza(session, st.presence({ from = bare_to, to = bare_from, type = \"unsubscribed\" }));", + { "rostermanager", "core_post_stanza", "st", "split_to", "bare_to", "bare_from" }; +end + +function action_handlers.REPORT_TO(spec) + local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$"); + if reason == "spam" then + reason = "urn:xmpp:reporting:spam"; + elseif reason == "abuse" or not reason then + reason = "urn:xmpp:reporting:abuse"; + end + local code = [[ + local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" }); + local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up(); + newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q }) + do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end + newstanza:up(); + core_post_stanza(session, newstanza); + ]]; + return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" }; +end + +return action_handlers; diff --git a/resources/prosody-plugins/mod_firewall/conditions.lib.lua b/resources/prosody-plugins/mod_firewall/conditions.lib.lua new file mode 100644 index 000000000000..e7cfb0e698f4 --- /dev/null +++ b/resources/prosody-plugins/mod_firewall/conditions.lib.lua @@ -0,0 +1,384 @@ +--luacheck: globals meta idsafe +local condition_handlers = {}; + +local jid = require "util.jid"; +local unpack = table.unpack or unpack; + +-- Helper to convert user-input strings (yes/true//no/false) to a bool +local function string_to_boolean(s) + s = s:lower(); + return s == "yes" or s == "true"; +end + +-- Return a code string for a condition that checks whether the contents +-- of variable with the name 'name' matches any of the values in the +-- comma/space/pipe delimited list 'values'. +local function compile_comparison_list(name, values) + local conditions = {}; + for value in values:gmatch("[^%s,|]+") do + table.insert(conditions, ("%s == %q"):format(name, value)); + end + return table.concat(conditions, " or "); +end + +function condition_handlers.KIND(kind) + assert(kind, "Expected stanza kind to match against"); + return compile_comparison_list("name", kind), { "name" }; +end + +local wildcard_equivs = { ["*"] = ".*", ["?"] = "." }; + +local function compile_jid_match_part(part, match) + if not match then + return part.." == nil"; + end + local pattern = match:match("^<(.*)>$"); + if pattern then + if pattern == "*" then + return part; + end + if pattern:find("^<.*>$") then + pattern = pattern:match("^<(.*)>$"); + else + pattern = pattern:gsub("%p", "%%%0"):gsub("%%(%p)", wildcard_equivs); + end + return ("(%s and %s:find(%q))"):format(part, part, "^"..pattern.."$"); + else + return ("%s == %q"):format(part, match); + end +end + +local function compile_jid_match(which, match_jid) + local match_node, match_host, match_resource = jid.split(match_jid); + local conditions = {}; + conditions[#conditions+1] = compile_jid_match_part(which.."_node", match_node); + conditions[#conditions+1] = compile_jid_match_part(which.."_host", match_host); + if match_resource then + conditions[#conditions+1] = compile_jid_match_part(which.."_resource", match_resource); + end + return table.concat(conditions, " and "); +end + +function condition_handlers.TO(to) + return compile_jid_match("to", to), { "split_to" }; +end + +function condition_handlers.FROM(from) + return compile_jid_match("from", from), { "split_from" }; +end + +function condition_handlers.FROM_FULL_JID() + return "not "..compile_jid_match_part("from_resource", nil), { "split_from" }; +end + +function condition_handlers.FROM_EXACTLY(from) + local metadeps = {}; + return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) }; +end + +function condition_handlers.TO_EXACTLY(to) + local metadeps = {}; + return ("to == %s"):format(metaq(to, metadeps)), { "to", unpack(metadeps) }; +end + +function condition_handlers.TO_SELF() + -- Intentionally not using 'to' here, as that defaults to bare JID when nil + return ("stanza.attr.to == nil"); +end + +function condition_handlers.TYPE(type) + assert(type, "Expected 'type' value to match against"); + return compile_comparison_list("(type or (name == 'message' and 'normal') or (name == 'presence' and 'available'))", type), { "type", "name" }; +end + +local function zone_check(zone, which) + local zone_var = zone; + if zone == "$local" then zone_var = "_local" end + local which_not = which == "from" and "to" or "from"; + return ("(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s]) " + .."and not(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s])" + ) + :format(zone_var, which, zone_var, which, zone_var, which, + zone_var, which_not, zone_var, which_not, zone_var, which_not), { + "split_to", "split_from", "bare_to", "bare_from", "zone:"..zone + }; +end + +function condition_handlers.ENTERING(zone) + return zone_check(zone, "to"); +end + +function condition_handlers.LEAVING(zone) + return zone_check(zone, "from"); +end + +-- IN ROSTER? (parameter is deprecated) +function condition_handlers.IN_ROSTER(yes_no) + local in_roster_requirement = string_to_boolean(yes_no or "yes"); -- COMPAT w/ older scripts + return "not "..(in_roster_requirement and "not" or "").." roster_entry", { "roster_entry" }; +end + +function condition_handlers.IN_ROSTER_GROUP(group) + return ("not not (roster_entry and roster_entry.groups[%q])"):format(group), { "roster_entry" }; +end + +function condition_handlers.SUBSCRIBED() + return "(bare_to == bare_from or to_node and rostermanager.is_contact_subscribed(to_node, to_host, bare_from))", + { "rostermanager", "split_to", "bare_to", "bare_from" }; +end + +function condition_handlers.PENDING_SUBSCRIPTION_FROM_SENDER() + return "(bare_to == bare_from or to_node and rostermanager.is_contact_pending_in(to_node, to_host, bare_from))", + { "rostermanager", "split_to", "bare_to", "bare_from" }; +end + +function condition_handlers.PAYLOAD(payload_ns) + return ("stanza:get_child(nil, %q)"):format(payload_ns); +end + +function condition_handlers.INSPECT(path) + if path:find("=") then + local query, match_type, value = path:match("(.-)([~/$]*)=(.*)"); + if not(query:match("#$") or query:match("@[^/]+")) then + error("Stanza path does not return a string (append # for text content or @name for value of named attribute)", 0); + end + local meta_deps = {}; + local quoted_value = ("%q"):format(value); + if match_type:find("$", 1, true) then + match_type = match_type:gsub("%$", ""); + quoted_value = meta(quoted_value, meta_deps); + end + if match_type == "~" then -- Lua pattern match + return ("(stanza:find(%q) or ''):match(%s)"):format(query, quoted_value), meta_deps; + elseif match_type == "/" then -- find literal substring + return ("(stanza:find(%q) or ''):find(%s, 1, true)"):format(query, quoted_value), meta_deps; + elseif match_type == "" then -- exact match + return ("stanza:find(%q) == %s"):format(query, quoted_value), meta_deps; + else + error("Unrecognised comparison '"..match_type.."='", 0); + end + end + return ("stanza:find(%q)"):format(path); +end + +function condition_handlers.FROM_GROUP(group_name) + return ("group_contains(%q, bare_from)"):format(group_name), { "group_contains", "bare_from" }; +end + +function condition_handlers.TO_GROUP(group_name) + return ("group_contains(%q, bare_to)"):format(group_name), { "group_contains", "bare_to" }; +end + +function condition_handlers.CROSSING_GROUPS(group_names) + local code = {}; + for group_name in group_names:gmatch("([^, ][^,]+)") do + group_name = group_name:match("^%s*(.-)%s*$"); -- Trim leading/trailing whitespace + -- Just check that's it is crossing from outside group to inside group + table.insert(code, ("(group_contains(%q, bare_to) and group_contains(%q, bare_from))"):format(group_name, group_name)) + end + return "not "..table.concat(code, " or "), { "group_contains", "bare_to", "bare_from" }; +end + +-- COMPAT w/0.12: Deprecated +function condition_handlers.FROM_ADMIN_OF(host) + return ("is_admin(bare_from, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_from" }; +end + +-- COMPAT w/0.12: Deprecated +function condition_handlers.TO_ADMIN_OF(host) + return ("is_admin(bare_to, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_to" }; +end + +-- COMPAT w/0.12: Deprecated +function condition_handlers.FROM_ADMIN() + return ("is_admin(bare_from, current_host)"), { "is_admin", "bare_from", "current_host" }; +end + +-- COMPAT w/0.12: Deprecated +function condition_handlers.TO_ADMIN() + return ("is_admin(bare_to, current_host)"), { "is_admin", "bare_to", "current_host" }; +end + +-- MAY: permission_to_check +function condition_handlers.MAY(permission_to_check) + return ("module:may(%q, event)"):format(permission_to_check); +end + +function condition_handlers.TO_ROLE(role_name) + return ("get_jid_role(bare_to, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_to" }; +end + +function condition_handlers.FROM_ROLE(role_name) + return ("get_jid_role(bare_from, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_from" }; +end + +local day_numbers = { sun = 0, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7 }; + +local function current_time_check(op, hour, minute) + hour, minute = tonumber(hour), tonumber(minute); + local adj_op = op == "<" and "<" or ">="; -- Start time inclusive, end time exclusive + if minute == 0 then + return "(current_hour"..adj_op..hour..")"; + else + return "((current_hour"..op..hour..") or (current_hour == "..hour.." and current_minute"..adj_op..minute.."))"; + end +end + +local function resolve_day_number(day_name) + return assert(day_numbers[day_name:sub(1,3):lower()], "Unknown day name: "..day_name); +end + +function condition_handlers.DAY(days) + local conditions = {}; + for day_range in days:gmatch("[^,]+") do + local day_start, day_end = day_range:match("(%a+)%s*%-%s*(%a+)"); + if day_start and day_end then + local day_start_num, day_end_num = resolve_day_number(day_start), resolve_day_number(day_end); + local op = "and"; + if day_end_num < day_start_num then + op = "or"; + end + table.insert(conditions, ("current_day >= %d %s current_day <= %d"):format(day_start_num, op, day_end_num)); + elseif day_range:find("%a") then + local day = resolve_day_number(day_range:match("%a+")); + table.insert(conditions, "current_day == "..day); + else + error("Unable to parse day/day range: "..day_range); + end + end + assert(#conditions>0, "Expected a list of days or day ranges"); + return "("..table.concat(conditions, ") or (")..")", { "time:day" }; +end + +function condition_handlers.TIME(ranges) + local conditions = {}; + for range in ranges:gmatch("([^,]+)") do + local clause = {}; + range = range:lower() + :gsub("(%d+):?(%d*) *am", function (h, m) return tostring(tonumber(h)%12)..":"..(tonumber(m) or "00"); end) + :gsub("(%d+):?(%d*) *pm", function (h, m) return tostring(tonumber(h)%12+12)..":"..(tonumber(m) or "00"); end); + local start_hour, start_minute = range:match("(%d+):(%d+) *%-"); + local end_hour, end_minute = range:match("%- *(%d+):(%d+)"); + local op = tonumber(start_hour) > tonumber(end_hour) and " or " or " and "; + if start_hour and end_hour then + table.insert(clause, current_time_check(">", start_hour, start_minute)); + table.insert(clause, current_time_check("<", end_hour, end_minute)); + end + if #clause == 0 then + error("Unable to parse time range: "..range); + end + table.insert(conditions, "("..table.concat(clause, " "..op.." ")..")"); + end + return table.concat(conditions, " or "), { "time:hour,min" }; +end + +function condition_handlers.LIMIT(spec) + local name, param = spec:match("^(%w+) on (.+)$"); + local meta_deps = {}; + + if not name then + name = spec:match("^%w+$"); + if not name then + error("Unable to parse LIMIT specification"); + end + else + param = meta(("%q"):format(param), meta_deps); + end + + if not param then + return ("not global_throttle_%s:poll(1)"):format(name), { "globalthrottle:"..name, unpack(meta_deps) }; + end + return ("not multi_throttle_%s:poll_on(%s, 1)"):format(name, param), { "multithrottle:"..name, unpack(meta_deps) }; +end + +function condition_handlers.ORIGIN_MARKED(name_and_time) + local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$"); + if not name then + name = name_and_time:match("^%s*([%w_]+)%s*$"); + end + if not name then + error("Error parsing mark name, see documentation for usage examples"); + end + if time then + return ("(current_timestamp - (session.firewall_marked_%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" }; + end + return ("not not session.firewall_marked_"..idsafe(name)); +end + +function condition_handlers.USER_MARKED(name_and_time) + local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$"); + if not name then + name = name_and_time:match("^%s*([%w_]+)%s*$"); + end + if not name then + error("Error parsing mark name, see documentation for usage examples"); + end + if time then + return ([[( + current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0) + ) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" }; + end + return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")"); +end + +function condition_handlers.SENT_DIRECTED_PRESENCE_TO_SENDER() + return "not not (session.directed and session.directed[from])", { "from" }; +end + +-- TO FULL JID? +function condition_handlers.TO_FULL_JID() + return "not not full_sessions[to]", { "to", "full_sessions" }; +end + +-- CHECK LIST: spammers contains $<@from> +function condition_handlers.CHECK_LIST(list_condition) + local list_name, expr = list_condition:match("(%S+) contains (.+)$"); + if not (list_name and expr) then + error("Error parsing list check, syntax: LISTNAME contains EXPRESSION"); + end + local meta_deps = {}; + expr = meta(("%q"):format(expr), meta_deps); + return ("list_%s:contains(%s) == true"):format(list_name, expr), { "list:"..list_name, unpack(meta_deps) }; +end + +-- SCAN: body for word in badwords +function condition_handlers.SCAN(scan_expression) + local search_name, pattern_name, list_name = scan_expression:match("(%S+) for (%S+) in (%S+)$"); + if not (search_name) then + error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST"); + end + return ("scan_list(list_%s, %s)"):format( + list_name, + "tokens_"..search_name.."_"..pattern_name + ), { + "scan_list", + "tokens:"..search_name.."-"..pattern_name, "list:"..list_name + }; +end + +-- COUNT: lines in body < 10 +local valid_comp_ops = { [">"] = ">", ["<"] = "<", ["="] = "==", ["=="] = "==", ["<="] = "<=", [">="] = ">=" }; +function condition_handlers.COUNT(count_expression) + local pattern_name, search_name, comparator_expression = count_expression:match("(%S+) in (%S+) (.+)$"); + if not (pattern_name) then + error("Error parsing COUNT expression, syntax: PATTERN in SEARCH COMPARATOR"); + end + local value; + comparator_expression = comparator_expression:gsub("%d+", function (value_string) + value = tonumber(value_string); + return ""; + end); + if not value then + error("Error parsing COUNT expression, expected value"); + end + local comp_op = comparator_expression:gsub("%s+", ""); + assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op); + return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format( + search_name, pattern_name, comp_op, value + ), { + "it_count", + "search:"..search_name, "pattern:"..pattern_name + }; +end + +return condition_handlers; diff --git a/resources/prosody-plugins/mod_firewall/definitions.lib.lua b/resources/prosody-plugins/mod_firewall/definitions.lib.lua new file mode 100644 index 000000000000..a35ba8040302 --- /dev/null +++ b/resources/prosody-plugins/mod_firewall/definitions.lib.lua @@ -0,0 +1,335 @@ + +-- Name arguments are unused here +-- luacheck: ignore 212 + +local definition_handlers = {}; + +local http = require "net.http"; +local timer = require "util.timer"; +local set = require"util.set"; +local new_throttle = require "util.throttle".create; +local hashes = require "util.hashes"; +local jid = require "util.jid"; +local lfs = require "lfs"; + +local multirate_cache_size = module:get_option_number("firewall_multirate_cache_limit", 1000); + +function definition_handlers.ZONE(zone_name, zone_members) + local zone_member_list = {}; + for member in zone_members:gmatch("[^, ]+") do + zone_member_list[#zone_member_list+1] = member; + end + return set.new(zone_member_list)._items; +end + +-- Helper function used by RATE handler +local function evict_only_unthrottled(name, throttle) + throttle:update(); + -- Check whether the throttle is at max balance (i.e. totally safe to forget about it) + if throttle.balance < throttle.max then + -- Not safe to forget + return false; + end +end + +function definition_handlers.RATE(name, line) + local rate = assert(tonumber(line:match("([%d.]+)")), "Unable to parse rate"); + local burst = tonumber(line:match("%(%s*burst%s+([%d.]+)%s*%)")) or 1; + local max_throttles = tonumber(line:match("%(%s*entries%s+([%d]+)%s*%)")) or multirate_cache_size; + local deny_when_full = not line:match("%(allow overflow%)"); + return { + single = function () + return new_throttle(rate*burst, burst); + end; + + multi = function () + local cache = require "util.cache".new(max_throttles, deny_when_full and evict_only_unthrottled or nil); + return { + poll_on = function (_, key, amount) + assert(key, "no key"); + local throttle = cache:get(key); + if not throttle then + throttle = new_throttle(rate*burst, burst); + if not cache:set(key, throttle) then + module:log("warn", "Multirate '%s' has hit its maximum number of active throttles (%d), denying new events", name, max_throttles); + return false; + end + end + return throttle:poll(amount); + end; + } + end; + }; +end + +local list_backends = { + -- %LIST name: memory (limit: number) + memory = { + init = function (self, type, opts) + if opts.limit then + local have_cache_lib, cache_lib = pcall(require, "util.cache"); + if not have_cache_lib then + error("In-memory lists with a size limit require Prosody 0.10"); + end + self.cache = cache_lib.new((assert(tonumber(opts.limit), "Invalid list limit"))); + if not self.cache.table then + error("In-memory lists with a size limit require a newer version of Prosody 0.10"); + end + self.items = self.cache:table(); + else + self.items = {}; + end + end; + add = function (self, item) + self.items[item] = true; + end; + remove = function (self, item) + self.items[item] = nil; + end; + contains = function (self, item) + return self.items[item] == true; + end; + }; + + -- %LIST name: http://example.com/ (ttl: number, pattern: pat, hash: sha1) + http = { + init = function (self, url, opts) + local poll_interval = assert(tonumber(opts.ttl or "3600"), "invalid ttl for <"..url.."> (expected number of seconds)"); + local pattern = opts.pattern or "([^\r\n]+)\r?\n"; + assert(pcall(string.match, "", pattern), "invalid pattern for <"..url..">"); + if opts.hash then + assert(opts.hash:match("^%w+$") and type(hashes[opts.hash]) == "function", "invalid hash function: "..opts.hash); + self.hash_function = hashes[opts.hash]; + end + local etag; + local failure_count = 0; + local retry_intervals = { 60, 120, 300 }; + -- By default only check the certificate if net.http supports SNI + local sni_supported = http.feature and http.features.sni; + local insecure = false; + if opts.checkcert == "never" then + insecure = true; + elseif (opts.checkcert == nil or opts.checkcert == "when-sni") and not sni_supported then + insecure = false; + end + local function update_list() + http.request(url, { + insecure = insecure; + headers = { + ["If-None-Match"] = etag; + }; + }, function (body, code, response) + local next_poll = poll_interval; + if code == 200 and body then + etag = response.headers.etag; + local items = {}; + for entry in body:gmatch(pattern) do + items[entry] = true; + end + self.items = items; + module:log("debug", "Fetched updated list from <%s>", url); + elseif code == 304 then + module:log("debug", "List at <%s> is unchanged", url); + elseif code == 0 or (code >= 400 and code <=599) then + module:log("warn", "Failed to fetch list from <%s>: %d %s", url, code, tostring(body)); + failure_count = failure_count + 1; + next_poll = retry_intervals[failure_count] or retry_intervals[#retry_intervals]; + end + if next_poll > 0 then + timer.add_task(next_poll+math.random(0, 60), update_list); + end + end); + end + update_list(); + end; + add = function () + end; + remove = function () + end; + contains = function (self, item) + if self.hash_function then + item = self.hash_function(item); + end + return self.items and self.items[item] == true; + end; + }; + + -- %LIST: file:/path/to/file + file = { + init = function (self, file_spec, opts) + local n, items = 0, {}; + self.items = items; + local filename = file_spec:gsub("^file:", ""); + if opts.missing == "ignore" and not lfs.attributes(filename, "mode") then + module:log("debug", "Ignoring missing list file: %s", filename); + return; + end + local file, err = io.open(filename); + if not file then + module:log("warn", "Failed to open list from %s: %s", filename, err); + return; + else + for line in file:lines() do + if not items[line] then + n = n + 1; + items[line] = true; + end + end + end + module:log("debug", "Loaded %d items from %s", n, filename); + end; + add = function (self, item) + self.items[item] = true; + end; + remove = function (self, item) + self.items[item] = nil; + end; + contains = function (self, item) + return self.items and self.items[item] == true; + end; + }; + + -- %LIST: pubsub:pubsub.example.com/node + -- TODO or the actual URI scheme? Bit overkill maybe? + -- TODO Publish items back to the service? + -- Step 1: Receiving pubsub events and storing them in the list + -- We'll start by using only the item id. + -- TODO Invent some custom schema for this? Needed for just a set of strings? + pubsubitemid = { + init = function(self, pubsub_spec, opts) + local service_addr, node = pubsub_spec:match("^pubsubitemid:([^/]*)/(.*)"); + if not service_addr then + module:log("warn", "Invalid list specification (expected 'pubsubitemid:<service>/<node>', got: '%s')", pubsub_spec); + return; + end + module:depends("pubsub_subscription"); + module:add_item("pubsub-subscription", { + service = service_addr; + node = node; + on_subscribed = function () + self.items = {}; + end; + on_item = function (event) + self:add(event.item.attr.id); + end; + on_retract = function (event) + self:remove(event.item.attr.id); + end; + on_purge = function () + self.items = {}; + end; + on_unsubscribed = function () + self.items = nil; + end; + on_delete= function () + self.items = nil; + end; + }); + -- TODO Initial fetch? Or should mod_pubsub_subscription do this? + end; + add = function (self, item) + if self.items then + self.items[item] = true; + end + end; + remove = function (self, item) + if self.items then + self.items[item] = nil; + end + end; + contains = function (self, item) + return self.items and self.items[item] == true; + end; + }; +}; +list_backends.https = list_backends.http; + +local normalize_functions = { + upper = string.upper, lower = string.lower; + md5 = hashes.md5, sha1 = hashes.sha1, sha256 = hashes.sha256; + prep = jid.prep, bare = jid.bare; +}; + +local function wrap_list_method(list_method, filter) + return function (self, item) + return list_method(self, filter(item)); + end +end + +local function create_list(list_backend, list_def, opts) + if not list_backends[list_backend] then + error("Unknown list type '"..list_backend.."'", 0); + end + local list = setmetatable({}, { __index = list_backends[list_backend] }); + if list.init then + list:init(list_def, opts); + end + if opts.filter then + local filters = {}; + for func_name in opts.filter:gmatch("[%w_]+") do + if func_name == "log" then + table.insert(filters, function (s) + --print("&&&&&", s); + module:log("debug", "Checking list <%s> for: %s", list_def, s); + return s; + end); + else + assert(normalize_functions[func_name], "Unknown list filter: "..func_name); + table.insert(filters, normalize_functions[func_name]); + end + end + + local filter; + local n = #filters; + if n == 1 then + filter = filters[1]; + else + function filter(s) + for i = 1, n do + s = filters[i](s or ""); + end + return s; + end + end + + list.add = wrap_list_method(list.add, filter); + list.remove = wrap_list_method(list.remove, filter); + list.contains = wrap_list_method(list.contains, filter); + end + return list; +end + +--[[ +%LIST spammers: memory (source: /etc/spammers.txt) + +%LIST spammers: memory (source: /etc/spammers.txt) + + +%LIST spammers: http://example.com/blacklist.txt +]] + +function definition_handlers.LIST(list_name, list_definition) + local list_backend = list_definition:match("^%w+"); + local opts = {}; + local opt_string = list_definition:match("^%S+%s+%((.+)%)"); + if opt_string then + for opt_k, opt_v in opt_string:gmatch("(%w+): ?([^,]+)") do + opts[opt_k] = opt_v; + end + end + return create_list(list_backend, list_definition:match("^%S+"), opts); +end + +function definition_handlers.PATTERN(name, pattern) + local ok, err = pcall(string.match, "", pattern); + if not ok then + error("Invalid pattern '"..name.."': "..err); + end + return pattern; +end + +function definition_handlers.SEARCH(name, pattern) + return pattern; +end + +return definition_handlers; diff --git a/resources/prosody-plugins/mod_firewall/marks.lib.lua b/resources/prosody-plugins/mod_firewall/marks.lib.lua new file mode 100644 index 000000000000..3c9bbb0661a7 --- /dev/null +++ b/resources/prosody-plugins/mod_firewall/marks.lib.lua @@ -0,0 +1,35 @@ +local mark_storage = module:open_store("firewall_marks"); +local mark_map_storage = module:open_store("firewall_marks", "map"); + +local user_sessions = prosody.hosts[module.host].sessions; + +module:hook("firewall/marked/user", function (event) + local user = user_sessions[event.username]; + local marks = user and user.firewall_marks; + if user and not marks then + -- Load marks from storage to cache on the user object + marks = mark_storage:get(event.username) or {}; + user.firewall_marks = marks; --luacheck: ignore 122 + end + if marks then + marks[event.mark] = event.timestamp; + end + local ok, err = mark_map_storage:set(event.username, event.mark, event.timestamp); + if not ok then + module:log("error", "Failed to mark user %q with %q: %s", event.username, event.mark, err); + end + return true; +end, -1); + +module:hook("firewall/unmarked/user", function (event) + local user = user_sessions[event.username]; + local marks = user and user.firewall_marks; + if marks then + marks[event.mark] = nil; + end + local ok, err = mark_map_storage:set(event.username, event.mark, nil); + if not ok then + module:log("error", "Failed to unmark user %q with %q: %s", event.username, event.mark, err); + end + return true; +end, -1); diff --git a/resources/prosody-plugins/mod_firewall/mod_firewall.lua b/resources/prosody-plugins/mod_firewall/mod_firewall.lua new file mode 100644 index 000000000000..9af541ed222b --- /dev/null +++ b/resources/prosody-plugins/mod_firewall/mod_firewall.lua @@ -0,0 +1,784 @@ + +local lfs = require "lfs"; +local resolve_relative_path = require "core.configmanager".resolve_relative_path; +local envload = require "util.envload".envload; +local logger = require "util.logger".init; +local it = require "util.iterators"; +local set = require "util.set"; + +local have_features, features = pcall(require, "core.features"); +features = have_features and features.available or set.new(); + +-- [definition_type] = definition_factory(param) +local definitions = module:shared("definitions"); + +-- When a definition instance has been instantiated, it lives here +-- [definition_type][definition_name] = definition_object +local active_definitions = { + ZONE = { + -- Default zone that includes all local hosts + ["$local"] = setmetatable({}, { __index = prosody.hosts }); + }; +}; + +local default_chains = { + preroute = { + type = "event"; + priority = 0.1; + "pre-message/bare", "pre-message/full", "pre-message/host"; + "pre-presence/bare", "pre-presence/full", "pre-presence/host"; + "pre-iq/bare", "pre-iq/full", "pre-iq/host"; + }; + deliver = { + type = "event"; + priority = 0.1; + "message/bare", "message/full", "message/host"; + "presence/bare", "presence/full", "presence/host"; + "iq/bare", "iq/full", "iq/host"; + }; + deliver_remote = { + type = "event"; "route/remote"; + priority = 0.1; + }; +}; + +local extra_chains = module:get_option("firewall_extra_chains", {}); + +local chains = {}; +for k,v in pairs(default_chains) do + chains[k] = v; +end +for k,v in pairs(extra_chains) do + chains[k] = v; +end + +-- Returns the input if it is safe to be used as a variable name, otherwise nil +function idsafe(name) + return name:match("^%a[%w_]*$"); +end + +local meta_funcs = { + bare = function (code) + return "jid_bare("..code..")", {"jid_bare"}; + end; + node = function (code) + return "(jid_split("..code.."))", {"jid_split"}; + end; + host = function (code) + return "(select(2, jid_split("..code..")))", {"jid_split"}; + end; + resource = function (code) + return "(select(3, jid_split("..code..")))", {"jid_split"}; + end; +}; + +-- Run quoted (%q) strings through this to allow them to contain code. e.g.: LOG=Received: $(stanza:top_tag()) +function meta(s, deps, extra) + return (s:gsub("$(%b())", function (expr) + expr = expr:gsub("\\(.)", "%1"); + return [["..tostring(]]..expr..[[).."]]; + end) + :gsub("$(%b<>)", function (expr) + expr = expr:sub(2,-2); + local default = "<undefined>"; + expr = expr:gsub("||(%b\"\")$", function (default_string) + default = stripslashes(default_string:sub(2,-2)); + return ""; + end); + local func_chain = expr:match("|[%w|]+$"); + if func_chain then + expr = expr:sub(1, -1-#func_chain); + end + local code; + if expr:match("^@") then + -- Skip stanza:find() for simple attribute lookup + local attr_name = expr:sub(2); + if deps and (attr_name == "to" or attr_name == "from" or attr_name == "type") then + -- These attributes may be cached in locals + code = attr_name; + table.insert(deps, attr_name); + else + code = "stanza.attr["..("%q"):format(attr_name).."]"; + end + elseif expr:match("^%w+#$") then + code = ("stanza:get_child_text(%q)"):format(expr:sub(1, -2)); + else + code = ("stanza:find(%q)"):format(expr); + end + if func_chain then + for func_name in func_chain:gmatch("|(%w+)") do + -- to/from are already available in local variables, use those if possible + if (code == "to" or code == "from") and func_name == "bare" then + code = "bare_"..code; + table.insert(deps, code); + elseif (code == "to" or code == "from") and (func_name == "node" or func_name == "host" or func_name == "resource") then + table.insert(deps, "split_"..code); + code = code.."_"..func_name; + else + assert(meta_funcs[func_name], "unknown function: "..func_name); + local new_code, new_deps = meta_funcs[func_name](code); + code = new_code; + if new_deps and #new_deps > 0 then + assert(deps, "function not supported here: "..func_name); + for _, dep in ipairs(new_deps) do + table.insert(deps, dep); + end + end + end + end + end + return "\"..tostring("..code.." or "..("%q"):format(default)..")..\""; + end) + :gsub("$$(%a+)", extra or {}) + :gsub([[^""%.%.]], "") + :gsub([[%.%.""$]], "")); +end + +function metaq(s, ...) + return meta(("%q"):format(s), ...); +end + +local escape_chars = { + a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t", + v = "\v", ["\\"] = "\\", ["\""] = "\"", ["\'"] = "\'" +}; +function stripslashes(s) + return (s:gsub("\\(.)", escape_chars)); +end + +-- Dependency locations: +-- <type lib> +-- <type global> +-- function handler() +-- <local deps> +-- if <conditions> then +-- <actions> +-- end +-- end + +local available_deps = { + st = { global_code = [[local st = require "util.stanza";]]}; + it = { global_code = [[local it = require "util.iterators";]]}; + it_count = { global_code = [[local it_count = it.count;]], depends = { "it" } }; + current_host = { global_code = [[local current_host = module.host;]] }; + jid_split = { + global_code = [[local jid_split = require "util.jid".split;]]; + }; + jid_bare = { + global_code = [[local jid_bare = require "util.jid".bare;]]; + }; + to = { local_code = [[local to = stanza.attr.to or jid_bare(session.full_jid);]]; depends = { "jid_bare" } }; + from = { local_code = [[local from = stanza.attr.from;]] }; + type = { local_code = [[local type = stanza.attr.type;]] }; + name = { local_code = [[local name = stanza.name;]] }; + split_to = { -- The stanza's split to address + depends = { "jid_split", "to" }; + local_code = [[local to_node, to_host, to_resource = jid_split(to);]]; + }; + split_from = { -- The stanza's split from address + depends = { "jid_split", "from" }; + local_code = [[local from_node, from_host, from_resource = jid_split(from);]]; + }; + bare_to = { depends = { "jid_bare", "to" }, local_code = "local bare_to = jid_bare(to)"}; + bare_from = { depends = { "jid_bare", "from" }, local_code = "local bare_from = jid_bare(from)"}; + group_contains = { + global_code = [[local group_contains = module:depends("groups").group_contains]]; + }; + is_admin = require"core.usermanager".is_admin and { global_code = [[local is_admin = require "core.usermanager".is_admin;]]} or nil; + get_jid_role = require "core.usermanager".get_jid_role and { global_code = [[local get_jid_role = require "core.usermanager".get_jid_role;]] } or nil; + core_post_stanza = { global_code = [[local core_post_stanza = prosody.core_post_stanza;]] }; + zone = { global_code = function (zone) + local var = zone; + if var == "$local" then + var = "_local"; -- See #1090 + else + assert(idsafe(var), "Invalid zone name: "..zone); + end + return ("local zone_%s = zones[%q] or {};"):format(var, zone); + end }; + date_time = { global_code = [[local os_date = os.date]]; local_code = [[local current_date_time = os_date("*t");]] }; + time = { local_code = function (what) + local defs = {}; + for field in what:gmatch("%a+") do + table.insert(defs, ("local current_%s = current_date_time.%s;"):format(field, field)); + end + return table.concat(defs, " "); + end, depends = { "date_time" }; }; + timestamp = { global_code = [[local get_time = require "socket".gettime;]]; local_code = [[local current_timestamp = get_time();]]; }; + globalthrottle = { + global_code = function (throttle) + assert(idsafe(throttle), "Invalid rate limit name: "..throttle); + assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle); + return ("local global_throttle_%s = rates.%s:single();"):format(throttle, throttle); + end; + }; + multithrottle = { + global_code = function (throttle) + assert(pcall(require, "util.cache"), "Using LIMIT with 'on' requires Prosody 0.10 or higher"); + assert(idsafe(throttle), "Invalid rate limit name: "..throttle); + assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle); + return ("local multi_throttle_%s = rates.%s:multi();"):format(throttle, throttle); + end; + }; + full_sessions = { + global_code = [[local full_sessions = prosody.full_sessions;]]; + }; + rostermanager = { + global_code = [[local rostermanager = require "core.rostermanager";]]; + }; + roster_entry = { + local_code = [[local roster_entry = (to_node and rostermanager.load_roster(to_node, to_host) or {})[bare_from];]]; + depends = { "rostermanager", "split_to", "bare_from" }; + }; + list = { global_code = function (list) + assert(idsafe(list), "Invalid list name: "..list); + assert(active_definitions.LIST[list], "Unknown list: "..list); + return ("local list_%s = lists[%q];"):format(list, list); + end + }; + search = { + local_code = function (search_name) + local search_path = assert(active_definitions.SEARCH[search_name], "Undefined search path: "..search_name); + return ("local search_%s = tostring(stanza:find(%q) or \"\")"):format(search_name, search_path); + end; + }; + pattern = { + local_code = function (pattern_name) + local pattern = assert(active_definitions.PATTERN[pattern_name], "Undefined pattern: "..pattern_name); + return ("local pattern_%s = %q"):format(pattern_name, pattern); + end; + }; + tokens = { + local_code = function (search_and_pattern) + local search_name, pattern_name = search_and_pattern:match("^([^%-]+)-(.+)$"); + local code = ([[local tokens_%s_%s = {}; + if search_%s then + for s in search_%s:gmatch(pattern_%s) do + tokens_%s_%s[s] = true; + end + end + ]]):format(search_name, pattern_name, search_name, search_name, pattern_name, search_name, pattern_name); + return code, { "search:"..search_name, "pattern:"..pattern_name }; + end; + }; + scan_list = { + global_code = [[local function scan_list(list, items) for item in pairs(items) do if list:contains(item) then return true; end end end]]; + } +}; + +local function include_dep(dependency, code) + local dep, dep_param = dependency:match("^([^:]+):?(.*)$"); + local dep_info = available_deps[dep]; + if not dep_info then + module:log("error", "Dependency not found: %s", dep); + return; + end + if code.included_deps[dependency] ~= nil then + if code.included_deps[dependency] ~= true then + module:log("error", "Circular dependency on %s", dep); + end + return; + end + code.included_deps[dependency] = false; -- Pending flag (used to detect circular references) + for _, dep_dep in ipairs(dep_info.depends or {}) do + include_dep(dep_dep, code); + end + if dep_info.global_code then + if dep_param ~= "" then + local global_code, deps = dep_info.global_code(dep_param); + if deps then + for _, dep_dep in ipairs(deps) do + include_dep(dep_dep, code); + end + end + table.insert(code.global_header, global_code); + else + table.insert(code.global_header, dep_info.global_code); + end + end + if dep_info.local_code then + if dep_param ~= "" then + local local_code, deps = dep_info.local_code(dep_param); + if deps then + for _, dep_dep in ipairs(deps) do + include_dep(dep_dep, code); + end + end + table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..local_code.."\n"); + else + table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..dep_info.local_code.."\n"); + end + end + code.included_deps[dependency] = true; +end + +local definition_handlers = module:require("definitions"); +local condition_handlers = module:require("conditions"); +local action_handlers = module:require("actions"); + +if module:get_option_boolean("firewall_experimental_user_marks", true) then + module:require"marks"; +end + +local function new_rule(ruleset, chain) + assert(chain, "no chain specified"); + local rule = { conditions = {}, actions = {}, deps = {} }; + table.insert(ruleset[chain], rule); + return rule; +end + +local function parse_firewall_rules(filename) + local line_no = 0; + + local function errmsg(err) + return "Error compiling "..filename.." on line "..line_no..": "..err; + end + + local ruleset = { + deliver = {}; + }; + + local chain = "deliver"; -- Default chain + local rule; + + local file, err = io.open(filename); + if not file then return nil, err; end + + local state; -- nil -> "rules" -> "actions" -> nil -> ... + + local line_hold; + for line in file:lines() do + line = line:match("^%s*(.-)%s*$"); + if line_hold and line:sub(-1,-1) ~= "\\" then + line = line_hold..line; + line_hold = nil; + elseif line:sub(-1,-1) == "\\" then + line_hold = (line_hold or "")..line:sub(1,-2); + end + line_no = line_no + 1; + + if line_hold or line:find("^[#;]") then -- luacheck: ignore 542 + -- No action; comment or partial line + elseif line == "" then + if state == "rules" then + return nil, ("Expected an action on line %d for preceding criteria") + :format(line_no); + end + state = nil; + elseif not(state) and line:sub(1, 2) == "::" then + chain = line:gsub("^::%s*", ""); + local chain_info = chains[chain]; + if not chain_info then + if chain:match("^user/") then + chains[chain] = { type = "event", priority = 1, pass_return = false }; + else + return nil, errmsg("Unknown chain: "..chain); + end + elseif chain_info.type ~= "event" then + return nil, errmsg("Only event chains supported at the moment"); + end + ruleset[chain] = ruleset[chain] or {}; + elseif not(state) and line:sub(1,1) == "%" then -- Definition (zone, limit, etc.) + local what, name = line:match("^%%%s*([%w_]+) +([^ :]+)"); + if not definition_handlers[what] then + return nil, errmsg("Definition of unknown object: "..what); + elseif not name or not idsafe(name) then + return nil, errmsg("Invalid "..what.." name"); + end + + local val = line:match(": ?(.*)$"); + if not val and line:find(":<") then -- Read from file + local fn = line:match(":< ?(.-)%s*$"); + if not fn then + return nil, errmsg("Unable to parse filename"); + end + local f, err = io.open(fn); + if not f then return nil, errmsg(err); end + val = f:read("*a"):gsub("\r?\n", " "):gsub("%s+$", ""); + end + if not val then + return nil, errmsg("No value given for definition"); + end + val = stripslashes(val); + local ok, ret = pcall(definition_handlers[what], name, val); + if not ok then + return nil, errmsg(ret); + end + + if not active_definitions[what] then + active_definitions[what] = {}; + end + active_definitions[what][name] = ret; + elseif line:find("^[%w_ ]+[%.=]") then + -- Action + if state == nil then + -- This is a standalone action with no conditions + rule = new_rule(ruleset, chain); + end + state = "actions"; + -- Action handlers? + local action = line:match("^[%w_ ]+"):upper():gsub(" ", "_"); + if not action_handlers[action] then + return nil, ("Unknown action on line %d: %s"):format(line_no, action or "<unknown>"); + end + table.insert(rule.actions, "-- "..line) + local ok, action_string, action_deps = pcall(action_handlers[action], line:match("=(.+)$")); + if not ok then + return nil, errmsg(action_string); + end + table.insert(rule.actions, action_string); + for _, dep in ipairs(action_deps or {}) do + table.insert(rule.deps, dep); + end + elseif state == "actions" then -- state is actions but action pattern did not match + state = nil; -- Awaiting next rule, etc. + table.insert(ruleset[chain], rule); + rule = nil; + else + if not state then + state = "rules"; + rule = new_rule(ruleset, chain); + end + -- Check standard modifiers for the condition (e.g. NOT) + local negated; + local condition = line:match("^[^:=%.?]*"); + if condition:find("%f[%w]NOT%f[^%w]") then + local s, e = condition:match("%f[%w]()NOT()%f[^%w]"); + condition = (condition:sub(1,s-1)..condition:sub(e+1, -1)):match("^%s*(.-)%s*$"); + negated = true; + end + condition = condition:gsub(" ", "_"); + if not condition_handlers[condition] then + return nil, ("Unknown condition on line %d: %s"):format(line_no, (condition:gsub("_", " "))); + end + -- Get the code for this condition + local ok, condition_code, condition_deps = pcall(condition_handlers[condition], line:match(":%s?(.+)$")); + if not ok then + return nil, errmsg(condition_code); + end + if negated then condition_code = "not("..condition_code..")"; end + table.insert(rule.conditions, condition_code); + for _, dep in ipairs(condition_deps or {}) do + table.insert(rule.deps, dep); + end + end + end + return ruleset; +end + +local function process_firewall_rules(ruleset) + -- Compile ruleset and return complete code + + local chain_handlers = {}; + + -- Loop through the chains in the parsed ruleset (e.g. incoming, outgoing) + for chain_name, rules in pairs(ruleset) do + local code = { included_deps = {}, global_header = {} }; + local condition_uses = {}; + -- This inner loop assumes chain is an event-based, not a filter-based + -- chain (filter-based will be added later) + for _, rule in ipairs(rules) do + for _, condition in ipairs(rule.conditions) do + if condition:find("^not%(.+%)$") then + condition = condition:match("^not%((.+)%)$"); + end + condition_uses[condition] = (condition_uses[condition] or 0) + 1; + end + end + + local condition_cache, n_conditions = {}, 0; + for _, rule in ipairs(rules) do + for _, dep in ipairs(rule.deps) do + include_dep(dep, code); + end + table.insert(code, "\n\t\t"); + local rule_code; + if #rule.conditions > 0 then + for i, condition in ipairs(rule.conditions) do + local negated = condition:match("^not%(.+%)$"); + if negated then + condition = condition:match("^not%((.+)%)$"); + end + if condition_uses[condition] > 1 then + local name = condition_cache[condition]; + if not name then + n_conditions = n_conditions + 1; + name = "condition"..n_conditions; + condition_cache[condition] = name; + table.insert(code, "local "..name.." = "..condition..";\n\t\t"); + end + rule.conditions[i] = (negated and "not(" or "")..name..(negated and ")" or ""); + else + rule.conditions[i] = (negated and "not(" or "(")..condition..")"; + end + end + + rule_code = "if "..table.concat(rule.conditions, " and ").." then\n\t\t\t" + ..table.concat(rule.actions, "\n\t\t\t") + .."\n\t\tend\n"; + else + rule_code = table.concat(rule.actions, "\n\t\t"); + end + table.insert(code, rule_code); + end + + for name in pairs(definition_handlers) do + table.insert(code.global_header, 1, "local "..name:lower().."s = definitions."..name..";"); + end + + local code_string = "return function (definitions, fire_event, log, module, pass_return)\n\t" + ..table.concat(code.global_header, "\n\t") + .."\n\tlocal db = require 'util.debug';\n\n\t" + .."return function (event)\n\t\t" + .."local stanza, session = event.stanza, event.origin;\n" + ..table.concat(code, "") + .."\n\tend;\nend"; + + chain_handlers[chain_name] = code_string; + end + + return chain_handlers; +end + +local function compile_firewall_rules(filename) + local ruleset, err = parse_firewall_rules(filename); + if not ruleset then return nil, err; end + local chain_handlers = process_firewall_rules(ruleset); + return chain_handlers; +end + +-- Compile handler code into a factory that produces a valid event handler. Factory accepts +-- a value to be returned on PASS +local function compile_handler(code_string, filename) + -- Prepare event handler function + local chunk, err = envload(code_string, "="..filename, _G); + if not chunk then + return nil, "Error compiling (probably a compiler bug, please report): "..err; + end + local function fire_event(name, data) + return module:fire_event(name, data); + end + local init_ok, initialized_chunk = pcall(chunk); + if not init_ok then + return nil, "Error initializing compiled rules: "..initialized_chunk; + end + return function (pass_return) + return initialized_chunk(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues + end +end + +local function resolve_script_path(script_path) + local relative_to = prosody.paths.config; + if script_path:match("^module:") then + relative_to = module.path:sub(1, -#("/mod_"..module.name..".lua")); + script_path = script_path:match("^module:(.+)$"); + end + return resolve_relative_path(relative_to, script_path); +end + +-- [filename] = { last_modified = ..., events_hooked = { [name] = handler } } +local loaded_scripts = {}; + +function load_script(script) + script = resolve_script_path(script); + local last_modified = (lfs.attributes(script) or {}).modification or os.time(); + if loaded_scripts[script] then + if loaded_scripts[script].last_modified == last_modified then + return; -- Already loaded, and source file hasn't changed + end + module:log("debug", "Reloading %s", script); + -- Already loaded, but the source file has changed + -- unload it now, and we'll load the new version below + unload_script(script, true); + end + local chain_functions, err = compile_firewall_rules(script); + + if not chain_functions then + module:log("error", "Error compiling %s: %s", script, err or "unknown error"); + return; + end + + -- Loop through the chains in the script, and for each chain attach the compiled code to the + -- relevant events, keeping track in events_hooked so we can cleanly unload later + local events_hooked = {}; + for chain, handler_code in pairs(chain_functions) do + local new_handler, err = compile_handler(handler_code, "mod_firewall::"..chain); + if not new_handler then + module:log("error", "Compilation error for %s: %s", script, err); + else + local chain_definition = chains[chain]; + if chain_definition and chain_definition.type == "event" then + local handler = new_handler(chain_definition.pass_return); + for _, event_name in ipairs(chain_definition) do + events_hooked[event_name] = handler; + module:hook(event_name, handler, chain_definition.priority); + end + elseif not chain:sub(1, 5) == "user/" then + module:log("warn", "Unknown chain %q", chain); + end + local event_name, handler = "firewall/chains/"..chain, new_handler(false); + events_hooked[event_name] = handler; + module:hook(event_name, handler); + end + end + loaded_scripts[script] = { last_modified = last_modified, events_hooked = events_hooked }; + module:log("debug", "Loaded %s", script); +end + +--COMPAT w/0.9 (no module:unhook()!) +local function module_unhook(event, handler) + return module:unhook_object_event((hosts[module.host] or prosody).events, event, handler); +end + +function unload_script(script, is_reload) + script = resolve_script_path(script); + local script_info = loaded_scripts[script]; + if not script_info then + return; -- Script not loaded + end + local events_hooked = script_info.events_hooked; + for event_name, event_handler in pairs(events_hooked) do + module_unhook(event_name, event_handler); + events_hooked[event_name] = nil; + end + loaded_scripts[script] = nil; + if not is_reload then + module:log("debug", "Unloaded %s", script); + end +end + +-- Given a set of scripts (e.g. from config) figure out which ones need to +-- be loaded, which are already loaded but need unloading, and which to reload +function load_unload_scripts(script_list) + local wanted_scripts = script_list / resolve_script_path; + local currently_loaded = set.new(it.to_array(it.keys(loaded_scripts))); + local scripts_to_unload = currently_loaded - wanted_scripts; + for script in wanted_scripts do + -- If the script is already loaded, this is fine - it will + -- reload the script for us if the file has changed + load_script(script); + end + for script in scripts_to_unload do + unload_script(script); + end +end + +function module.load() + if not prosody.arg then return end -- Don't run in prosodyctl + local firewall_scripts = module:get_option_set("firewall_scripts", {}); + load_unload_scripts(firewall_scripts); + -- Replace contents of definitions table (shared) with active definitions + for k in it.keys(definitions) do definitions[k] = nil; end + for k,v in pairs(active_definitions) do definitions[k] = v; end +end + +function module.save() + return { active_definitions = active_definitions, loaded_scripts = loaded_scripts }; +end + +function module.restore(state) + active_definitions = state.active_definitions; + loaded_scripts = state.loaded_scripts; +end + +module:hook_global("config-reloaded", function () + load_unload_scripts(module:get_option_set("firewall_scripts", {})); +end); + +function module.command(arg) + if not arg[1] or arg[1] == "--help" then + require"util.prosodyctl".show_usage([[mod_firewall <firewall.pfw>]], [[Compile files with firewall rules to Lua code]]); + return 1; + end + local verbose = arg[1] == "-v"; + if verbose then table.remove(arg, 1); end + + if arg[1] == "test" then + table.remove(arg, 1); + return module:require("test")(arg); + end + + local serialize = require "util.serialization".serialize; + if verbose then + print("local logger = require \"util.logger\".init;"); + print(); + print("local function fire_event(name, data)\n\tmodule:fire_event(name, data)\nend"); + print(); + end + + for _, filename in ipairs(arg) do + filename = resolve_script_path(filename); + print("do -- File "..filename); + local chain_functions = assert(compile_firewall_rules(filename)); + if verbose then + print(); + print("local active_definitions = "..serialize(active_definitions)..";"); + print(); + end + local c = 0; + for chain, handler_code in pairs(chain_functions) do + c = c + 1; + print("---- Chain "..chain:gsub("_", " ")); + local chain_func_name = "chain_"..tostring(c).."_"..chain:gsub("%p", "_"); + if not verbose then + print(("%s = %s;"):format(chain_func_name, handler_code:sub(8))); + else + + print(("local %s = (%s)(active_definitions, fire_event, logger(%q));"):format(chain_func_name, handler_code:sub(8), filename)); + print(); + + local chain_definition = chains[chain]; + if chain_definition and chain_definition.type == "event" then + for _, event_name in ipairs(chain_definition) do + print(("module:hook(%q, %s, %d);"):format(event_name, chain_func_name, chain_definition.priority or 0)); + end + end + print(("module:hook(%q, %s, %d);"):format("firewall/chains/"..chain, chain_func_name, chain_definition.priority or 0)); + end + + print("---- End of chain "..chain); + print(); + end + print("end -- End of file "..filename); + end +end + + +-- Console + +local console_env = module:shared("/*/admin_shell/env"); + +console_env.firewall = {}; + +function console_env.firewall:mark(user_jid, mark_name) + local username, host = jid.split(user_jid); + if not username or not hosts[host] then + return nil, "Invalid JID supplied"; + elseif not idsafe(mark_name) then + return nil, "Invalid characters in mark name"; + end + if not module:context(host):fire_event("firewall/marked/user", { + username = session.username; + mark = mark_name; + timestamp = os.time(); + }) then + return nil, "Mark not set - is mod_firewall loaded on that host?"; + end + return true, "User marked"; +end + +function console_env.firewall:unmark(jid, mark_name) + local username, host = jid.split(user_jid); + if not username or not hosts[host] then + return nil, "Invalid JID supplied"; + elseif not idsafe(mark_name) then + return nil, "Invalid characters in mark name"; + end + if not module:context(host):fire_event("firewall/unmarked/user", { + username = session.username; + mark = mark_name; + }) then + return nil, "Mark not removed - is mod_firewall loaded on that host?"; + end + return true, "User unmarked"; +end diff --git a/resources/prosody-plugins/mod_firewall/test.lib.lua b/resources/prosody-plugins/mod_firewall/test.lib.lua new file mode 100644 index 000000000000..a72b02153b95 --- /dev/null +++ b/resources/prosody-plugins/mod_firewall/test.lib.lua @@ -0,0 +1,75 @@ +-- luacheck: globals load_unload_scripts +local set = require "util.set"; +local ltn12 = require "ltn12"; + +local xmppstream = require "util.xmppstream"; + +local function stderr(...) + io.stderr:write("** ", table.concat({...}, "\t", 1, select("#", ...)), "\n"); +end + +return function (arg) + require "net.http".request = function (url, ex, cb) + stderr("Making HTTP request to "..url); + local body_table = {}; + local ok, response_status, response_headers = require "ssl.https".request({ + url = url; + headers = ex.headers; + method = ex.body and "POST" or "GET"; + sink = ltn12.sink.table(body_table); + source = ex.body and ltn12.source.string(ex.body) or nil; + }); + stderr("HTTP response "..response_status); + cb(table.concat(body_table), response_status, { headers = response_headers }); + return true; + end; + + local stats_dropped, stats_passed = 0, 0; + + load_unload_scripts(set.new(arg)); + local stream_callbacks = { default_ns = "jabber:client" }; + + function stream_callbacks.streamopened(session) + session.notopen = nil; + end + function stream_callbacks.streamclosed() + end + function stream_callbacks.error(session, error_name, error_message) -- luacheck: ignore 212/session + stderr("Fatal error parsing XML stream: "..error_name..": "..tostring(error_message)) + assert(false); + end + function stream_callbacks.handlestanza(session, stanza) + if not module:fire_event("firewall/chains/deliver", { origin = session, stanza = stanza }) then + stats_passed = stats_passed + 1; + print(stanza); + print(""); + else + stats_dropped = stats_dropped + 1; + end + end + + local session = { notopen = true }; + function session.send(stanza) + stderr("Reply:", "\n"..tostring(stanza).."\n"); + end + local stream = xmppstream.new(session, stream_callbacks); + stream:feed("<stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client'>"); + local line_count = 0; + for line in io.lines() do + line_count = line_count + 1; + local ok, err = stream:feed(line.."\n"); + if not ok then + stderr("Fatal XML parse error on line "..line_count..": "..err); + return 1; + end + end + + stderr("Summary"); + stderr("-------"); + stderr(""); + stderr(stats_dropped + stats_passed, "processed"); + stderr(stats_passed, "passed"); + stderr(stats_dropped, "dropped"); + stderr(line_count, "input lines"); + stderr(""); +end diff --git a/resources/prosody-plugins/mod_log_ringbuffer.lua b/resources/prosody-plugins/mod_log_ringbuffer.lua new file mode 100644 index 000000000000..1521f10776ff --- /dev/null +++ b/resources/prosody-plugins/mod_log_ringbuffer.lua @@ -0,0 +1,120 @@ +module:set_global(); + +local loggingmanager = require "core.loggingmanager"; +local format = require "util.format".format; +local pposix = require "util.pposix"; +local rb = require "util.ringbuffer"; +local queue = require "util.queue"; + +local default_timestamp = "%b %d %H:%M:%S "; +local max_chunk_size = module:get_option_number("log_ringbuffer_chunk_size", 16384); + +local os_date = os.date; + +local default_filename_template = "{paths.data}/ringbuffer-logs-{pid}-{count}.log"; +local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, { + yyyymmdd = function (t) + return os_date("%Y%m%d", t); + end; + hhmmss = function (t) + return os_date("%H%M%S", t); + end; +}); + +local dump_count = 0; + +local function dump_buffer(dump, filename) + dump_count = dump_count + 1; + local f, err = io.open(filename, "a+"); + if not f then + module:log("error", "Unable to open output file: %s", err); + return; + end + f:write(("-- Dumping log buffer at %s --\n"):format(os_date(default_timestamp))); + dump(f); + f:write("-- End of dump --\n\n"); + f:close(); +end + +local function get_filename(filename_template) + filename_template = filename_template or default_filename_template; + return render_filename(filename_template, { + paths = prosody.paths; + pid = pposix.getpid(); + count = dump_count; + time = os.time(); + }); +end + +local function new_buffer(config) + local write, dump; + + if config.lines then + local buffer = queue.new(config.lines, true); + function write(line) + buffer:push(line); + end + function dump(f) + -- COMPAT w/0.11 - update to use :consume() + for line in buffer.pop, buffer do + f:write(line); + end + end + else + local buffer_size = config.size or 100*1024; + local buffer = rb.new(buffer_size); + function write(line) + if not buffer:write(line) then + if #line > buffer_size then + buffer:discard(buffer_size); + buffer:write(line:sub(-buffer_size)); + else + buffer:discard(#line); + buffer:write(line); + end + end + end + function dump(f) + local bytes_remaining = buffer:length(); + while bytes_remaining > 0 do + local chunk_size = math.min(bytes_remaining, max_chunk_size); + local chunk = buffer:read(chunk_size); + if not chunk then + return; + end + f:write(chunk); + bytes_remaining = bytes_remaining - chunk_size; + end + end + end + return write, dump; +end + +local function ringbuffer_log_sink_maker(sink_config) + local write, dump = new_buffer(sink_config); + + local timestamps = sink_config.timestamps; + + if timestamps == true or timestamps == nil then + timestamps = default_timestamp; -- Default format + elseif timestamps then + timestamps = timestamps .. " "; + end + + local function handler() + dump_buffer(dump, sink_config.filename or get_filename(sink_config.filename_template)); + end + + if sink_config.signal then + require "util.signal".signal(sink_config.signal, handler); + elseif sink_config.event then + module:hook_global(sink_config.event, handler); + end + + return function (name, level, message, ...) + local line = format("%s%s\t%s\t%s\n", timestamps and os_date(timestamps) or "", name, level, format(message, ...)); + write(line); + end; +end + +loggingmanager.register_sink_type("ringbuffer", ringbuffer_log_sink_maker); diff --git a/resources/prosody-plugins/mod_measure_stanza_counts.lua b/resources/prosody-plugins/mod_measure_stanza_counts.lua new file mode 100644 index 000000000000..6c17c6773947 --- /dev/null +++ b/resources/prosody-plugins/mod_measure_stanza_counts.lua @@ -0,0 +1,32 @@ +module:set_global() + +local filters = require"util.filters"; + +local stanzas_in = module:metric( + "counter", "received", "", + "Stanzas received by Prosody", + { "session_type", "stanza_kind" } +) +local stanzas_out = module:metric( + "counter", "sent", "", + "Stanzas sent by prosody", + { "session_type", "stanza_kind" } +) + +local stanza_kinds = { message = true, presence = true, iq = true }; + +local function rate(metric_family) + return function (stanza, session) + if stanza.attr and not stanza.attr.xmlns and stanza_kinds[stanza.name] then + metric_family:with_labels(session.type, stanza.name):add(1); + end + return stanza; + end +end + +local function measure_stanza_counts(session) + filters.add_filter(session, "stanzas/in", rate(stanzas_in)); + filters.add_filter(session, "stanzas/out", rate(stanzas_out)); +end + +filters.add_filter_hook(measure_stanza_counts);