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);