Changeset

4938:bc8832c6696b

upstream merge
author Goffi <goffi@goffi.org>
date Wed, 11 May 2022 12:44:32 +0200
parents 4937:3ddab718f717 (current diff) 4936:d63657a85fb4 (diff)
children 4939:1a58345d91a9 4953:7d6ae8bb95dc
files
diffstat 21 files changed, 473 insertions(+), 103 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit/README.md	Wed May 11 12:44:32 2022 +0200
@@ -0,0 +1,27 @@
+---
+summary: Audit Logging
+rockspec: {}
+...
+
+This module provides infrastructure for audit logging inside Prosody.
+
+## What is audit logging?
+
+Audit logs will contain security sensitive events, both for server-wide
+incidents as well as user-specific.
+
+This module, however, only provides the infrastructure for audit logging. It
+does not, by itself, generate such logs. For that, other modules, such as
+`mod_audit_auth` or `mod_audit_register` need to be loaded.
+
+## A note on privacy
+
+Audit logging is intended to ensure the security of a system. As such, its
+contents are often at the same time highly sensitive (containing user names
+and IP addresses, for instance) and allowed to be stored under common privacy
+regulations.
+
+Before using these modules, you may want to ensure that you are legally
+allowed to store the data for the amount of time these modules will store it.
+Note that it is currently not possible to store different event types with
+different expiration times.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit/mod_audit.lua	Wed May 11 12:44:32 2022 +0200
@@ -0,0 +1,85 @@
+module:set_global();
+
+local time_now = os.time;
+local st = require "util.stanza";
+local moduleapi = require "core.moduleapi";
+
+local host_wide_user = "@";
+
+local stores = {};
+
+local function get_store(self, host)
+	local store = rawget(self, host);
+	if store then
+		return store
+	end
+	store = module:context(host):open_store("audit", "archive");
+	rawset(self, host, store);
+	return store;
+end
+
+setmetatable(stores, { __index = get_store });
+
+
+local function session_extra(session)
+	local attr = {
+		xmlns = "xmpp:prosody.im/audit",
+	};
+	if session.id then
+		attr.id = session.id;
+	end
+	if session.type then
+		attr.type = session.type;
+	end
+	local stanza = st.stanza("session", attr);
+	if session.ip then
+		stanza:text_tag("remote-ip", session.ip);
+	end
+	return stanza
+end
+
+local function audit(host, user, source, event_type, extra)
+	if not host or host == "*" then
+		error("cannot log audit events for global");
+	end
+	local user_key = user or host_wide_user;
+
+	local attr = {
+		["source"] = source,
+		["type"] = event_type,
+	};
+	if user_key ~= host_wide_user then
+		attr.user = user_key;
+	end
+	local stanza = st.stanza("audit-event", attr);
+	if extra ~= nil then
+		if extra.session then
+			local child = session_extra(extra.session);
+			if child then
+				stanza:add_child(child);
+			end
+		end
+		if extra.custom then
+			for _, child in extra.custom do
+				if not st.is_stanza(child) then
+					error("all extra.custom items must be stanzas")
+				end
+				stanza:add_child(child);
+			end
+		end
+	end
+
+	local id, err = stores[host]:append(nil, nil, stanza, time_now(), user_key);
+	if err then
+		module:log("error", "failed to persist audit event: %s", err);
+		return
+	else
+		module:log("debug", "persisted audit event %s as %s", stanza:top_tag(), id);
+	end
+end
+
+function moduleapi.audit(module, user, event_type, extra)
+	audit(module.host, user, "mod_" .. module:get_name(), event_type, extra);
+end
+
+module:hook("audit", audit, 0);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_auth/README.md	Wed May 11 12:44:32 2022 +0200
@@ -0,0 +1,9 @@
+---
+summary: Store authentication events in the audit log
+rockspec:
+  dependencies:
+  - mod_audit
+...
+
+This module stores authentication failures and authentication successes in the
+audit log provided by `mod_audit`.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_auth/mod_audit_auth.lua	Wed May 11 12:44:32 2022 +0200
@@ -0,0 +1,16 @@
+module:depends("audit");
+-- luacheck: read globals module.audit
+
+module:hook("authentication-failure", function(event)
+	local session = event.session;
+	module:audit(session.sasl_handler.username, "authentication-failure", {
+		session = session,
+	});
+end)
+
+module:hook("authentication-success", function(event)
+	local session = event.session;
+	module:audit(session.sasl_handler.username, "authentication-success", {
+		session = session,
+	});
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_register/README.md	Wed May 11 12:44:32 2022 +0200
@@ -0,0 +1,9 @@
+---
+summary: Store registration events in the audit log
+rockspec:
+  dependencies:
+  - mod_audit
+...
+
+This module stores successful user registrations in the audit log provided by
+`mod_audit`.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_register/mod_audit_register.lua	Wed May 11 12:44:32 2022 +0200
@@ -0,0 +1,23 @@
+module:depends("audit");
+-- luacheck: read globals module.audit
+
+local st = require "util.stanza";
+
+module:hook("user-registered", function(event)
+	local session = event.session;
+	local custom = {};
+	local invite = event.validated_invite or (event.session and event.session.validated_invite);
+	if invite then
+		table.insert(custom, st.stanza(
+			"invite-used",
+			{
+				xmlns = "xmpp:prosody.im/audit",
+				token = invite.token,
+			}
+		))
+	end
+	module:audit(event.username, "user-registered", {
+		session = session,
+		custom = custom,
+	});
+end);
--- a/mod_auth_cyrus/README.md	Wed May 11 12:43:26 2022 +0200
+++ b/mod_auth_cyrus/README.md	Wed May 11 12:44:32 2022 +0200
@@ -3,7 +3,7 @@
 rockspec:
   build:
     modules:
-      util.sasl_cyrus: sasl_cyrus.lua
+      mod_auth_cyrus.sasl_cyrus: sasl_cyrus.lua
 ---
 
 # Introduction
--- a/mod_auth_cyrus/mod_auth_cyrus.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_auth_cyrus/mod_auth_cyrus.lua	Wed May 11 12:44:32 2022 +0200
@@ -19,7 +19,7 @@
 
 prosody.unlock_globals(); --FIXME: Figure out why this is needed and
 						  -- why cyrussasl isn't caught by the sandbox
-local cyrus_new = require "util.sasl_cyrus".new;
+local cyrus_new = module:require "sasl_cyrus".new;
 prosody.lock_globals();
 local new_sasl = function(realm)
 	return cyrus_new(
--- a/mod_conversejs/README.markdown	Wed May 11 12:43:26 2022 +0200
+++ b/mod_conversejs/README.markdown	Wed May 11 12:44:32 2022 +0200
@@ -10,8 +10,6 @@
   build:
     copy_directories:
     - templates
-  dependencies:
-  - mod_bookmarks
 ---
 
 Introduction
--- a/mod_conversejs/mod_conversejs.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_conversejs/mod_conversejs.lua	Wed May 11 12:44:32 2022 +0200
@@ -1,5 +1,5 @@
 -- mod_conversejs
--- Copyright (C) 2017 Kim Alvefur
+-- Copyright (C) 2017-2022 Kim Alvefur
 
 local json_encode = require"util.json".encode;
 local xml_escape = require "util.stanza".xml_escape;
--- a/mod_http_admin_api/mod_http_admin_api.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_http_admin_api/mod_http_admin_api.lua	Wed May 11 12:44:32 2022 +0200
@@ -330,7 +330,8 @@
 					service = push_info.jid;
 					node = push_info.node;
 					error_count = push_errors[identifier] or 0;
-					client = push_info.client;
+					client_id = push_info.client_id;
+					encryption = not not push_info.encryption;
 				};
 			end
 		end
--- a/mod_http_muc_log/mod_http_muc_log.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_http_muc_log/mod_http_muc_log.lua	Wed May 11 12:44:32 2022 +0200
@@ -275,6 +275,10 @@
 	local request, response = event.request, event.response;
 
 	local room, date = path:match("^([^/]+)/([^/]*)/?$");
+	if not room then
+		response.headers.location = url.build({ path = path .. "/" });
+		return 303;
+	end
 	room = nodeprep(room);
 	if not room then
 		return 400;
--- a/mod_http_oauth2/README.markdown	Wed May 11 12:43:26 2022 +0200
+++ b/mod_http_oauth2/README.markdown	Wed May 11 12:44:32 2022 +0200
@@ -17,4 +17,4 @@
 Compatibility
 =============
 
-Requires Prosody trunk.
+Requires Prosody 0.12+ or trunk.
--- a/mod_rest/README.markdown	Wed May 11 12:43:26 2022 +0200
+++ b/mod_rest/README.markdown	Wed May 11 12:44:32 2022 +0200
@@ -130,7 +130,12 @@
 rest_callback_url = "http://my-api.example:9999/stanzas"
 ```
 
-To enable JSON payloads set
+The callback URL supports a few variables from the stanza being sent,
+namely `{kind}` (e.g. message, presence, iq or meta) and ones
+corresponding to stanza attributes: `{type}`, `{to}` and `{from}`.
+
+The preferred format can be indicated via the Accept header in response
+to an OPTIONS probe that mod_rest does on startup, or by configuring:
 
 ``` {.lua}
 rest_callback_content_type = "application/json"
@@ -164,6 +169,41 @@
 }
 ```
 
+### Which stanzas
+
+The set of stanzas routed to the callback is determined by these two
+settings:
+
+`rest_callback_stanzas`
+:   The stanza kinds to handle, defaults to `{ "message", "presence", "iq" }`
+
+`rest_callback_events`
+:   For the selected stanza kinds, which events to handle.  When loaded
+on a Component, this defaults to `{ "bare", "full", "host" }`, while on
+a VirtualHost the default is `{ "host" }`.
+
+Events correspond to which form of address was used in the `to`
+attribute of the stanza.
+
+bare
+:   `localpart@hostpart`
+
+full
+:   `localpart@hostpart/resourcepart`
+
+host
+:   `hostpart`
+
+The following example would handle only stanzas like `<message
+to="anything@hello.example"/>`
+
+```lua
+Component "hello.example" "rest"
+rest_callback_url = "http://hello.internal.example:9003/api"
+rest_callback_stanzas = { "message" }
+rest_callback_events = { "bare" }
+```
+
 ### Replying
 
 To accept the stanza without returning a reply, respond with HTTP status
@@ -236,9 +276,6 @@
 `status`
 :   Human-readable status message.
 
-`join`
-:   Boolean. Join a group chat.
-
 #### Info-Queries
 
 Only one type of payload can be included in an `iq`.
@@ -395,8 +432,8 @@
 
 ### Presence
 
-`join`
-:   Boolean, used to join group chats.
+`muc`
+:   Object with [MUC][XEP-0045] related properties.
 
 ### IQ
 
--- a/mod_rest/apidemo.lib.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_rest/apidemo.lib.lua	Wed May 11 12:44:32 2022 +0200
@@ -11,7 +11,12 @@
 	});
 
 local index do
-	local f = assert(io.open(api_demo.."/index.html"), "'rest_demo_resources' should point to the 'dist' directory");
+	local f, err = io.open(api_demo.."/index.html");
+	if not f then
+		module:log("error", "Could not open resource: %s", err);
+		module:log("error", "'rest_demo_resources' should point to the 'dist' directory");
+		return _M
+	end
 	index = f:read("*a");
 	f:close();
 
--- a/mod_rest/jsonmap.lib.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_rest/jsonmap.lib.lua	Wed May 11 12:44:32 2022 +0200
@@ -50,6 +50,8 @@
 				return s.attr.node or true;
 			end
 			local identities, features, extensions = array(), array(), {};
+
+			-- features and identities could be done with util.datamapper
 			for tag in s:childtags() do
 				if tag.name == "identity" and tag.attr.category and tag.attr.type then
 					identities:push({ category = tag.attr.category, type = tag.attr.type, name = tag.attr.name });
@@ -57,6 +59,8 @@
 					features:push(tag.attr.var);
 				end
 			end
+
+			-- Especially this would be hard to do with util.datamapper
 			for form in s:childtags("x", "jabber:x:data") do
 				local jform = field_mappings.formdata.st2json(form);
 				local form_type = jform["FORM_TYPE"];
@@ -65,6 +69,7 @@
 					extensions[form_type] = jform;
 				end
 			end
+
 			if next(extensions) == nil then extensions = nil; end
 			return { node = s.attr.node, identities = identities, features = features, extensions = extensions };
 		end;
@@ -211,26 +216,6 @@
 		end;
 	};
 
-	-- XEP-0432: Simple JSON Messaging
-	payload = { type = "func", xmlns = "urn:xmpp:json-msg:0", tagname = "payload",
-		st2json = function (s)
-			local rawjson = s:get_child_text("json", "urn:xmpp:json:0");
-			if not rawjson then return nil, "missing-json-payload"; end
-			local parsed, err = json.decode(rawjson);
-			if not parsed then return nil, err; end
-			return {
-				datatype = s.attr.datatype;
-				data = parsed;
-			};
-		end;
-		json2st = function (s)
-			if type(s) == "table" then
-				return st.stanza("payload", { xmlns = "urn:xmpp:json-msg:0", datatype = s.datatype })
-				:tag("json", { xmlns = "urn:xmpp:json:0" }):text(json.encode(s.data));
-			end;
-		end
-	};
-
 	-- XEP-0004: Data Forms
 	dataform = {
 		-- Generic and complete dataforms mapping
@@ -450,6 +435,19 @@
 		return t;
 	end
 
+	if type(t.payload) == "table" then
+		if type(t.payload.data) == "string" then
+			local data, err = json.decode(t.payload.data);
+			if err then
+				return nil, err;
+			else
+				t.payload.data = data;
+			end
+		else
+			return nil, "invalid payload.data";
+		end
+	end
+
 	for _, tag in ipairs(s.tags) do
 		local prefix = "{" .. (tag.attr.xmlns or "jabber:client") .. "}";
 		local mapping = byxmlname[prefix .. tag.name];
@@ -536,6 +534,15 @@
 		end
 	end
 
+	if type(t.payload) == "table" then
+		t.payload.data = json.encode(t.payload.data);
+	end
+
+	if kind == "presence" and t.join == true and t.muc == nil then
+		-- COMPAT Older boolean 'join' property used with XEP-0045
+		t.muc = {};
+	end
+
 	local s = map.unparse(schema, { [kind or "message"] = t }).tags[1];
 
 	s.attr.type = t_type;
@@ -552,8 +559,8 @@
 	for k, v in pairs(t) do
 		local mapping = field_mappings[k];
 		if mapping and mapping.type == "func" and mapping.json2st then
-				s:add_child(mapping.json2st(v)):up();
-			end
+			s:add_child(mapping.json2st(v)):up();
+		end
 	end
 
 	s:reset();
--- a/mod_rest/mod_rest.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_rest/mod_rest.lua	Wed May 11 12:44:32 2022 +0200
@@ -1,6 +1,6 @@
 -- RESTful API
 --
--- Copyright (c) 2019-2020 Kim Alvefur
+-- Copyright (c) 2019-2022 Kim Alvefur
 --
 -- This file is MIT/X11 licensed.
 
@@ -230,16 +230,21 @@
 end
 
 local function encode(type, s)
+	if type == "text/plain" then
+		return s:get_child_text("body") or "";
+	elseif type == "application/xmpp+xml" then
+		return tostring(s);
+	end
+	local mapped, err = jsonmap.st2json(s);
+	if not mapped then return mapped, err; end
 	if type == "application/json" then
-		return json.encode(jsonmap.st2json(s));
+		return json.encode(mapped);
 	elseif type == "application/x-www-form-urlencoded" then
-		return http.formencode(flatten(jsonmap.st2json(s)));
+		return http.formencode(flatten(mapped));
 	elseif type == "application/cbor" then
-		return cbor.encode(jsonmap.st2json(s));
-	elseif type == "text/plain" then
-		return s:get_child_text("body") or "";
+		return cbor.encode(mapped);
 	end
-	return tostring(s);
+	error "unsupported encoding";
 end
 
 local post_errors = errors.init("mod_rest", {
@@ -332,8 +337,10 @@
 	local send_type = decide_type((request.headers.accept or "") ..",".. (request.headers.content_type or ""), supported_outputs)
 
 	if echo then
+		local ret, err = errors.coerce(encode(send_type, payload));
+		if not ret then return err; end
 		response.headers.content_type = send_type;
-		return encode(send_type, payload);
+		return ret;
 	end
 
 	if payload.name == "iq" then
@@ -409,23 +416,35 @@
 -- Forward stanzas from XMPP to HTTP and return any reply
 local rest_url = module:get_option_string("rest_callback_url", nil);
 if rest_url then
+	local function get_url() return rest_url; end
+	if rest_url:find("%b{}") then
+		local httputil = require "util.http";
+		local render_url = require"util.interpolation".new("%b{}", httputil.urlencode);
+		function get_url(stanza)
+			local at = stanza.attr;
+			return render_url(rest_url, { kind = stanza.name, type = at.type, to = at.to, from = at.from });
+		end
+	end
 	local send_type = module:get_option_string("rest_callback_content_type", "application/xmpp+xml");
 	if send_type == "json" then
 		send_type = "application/json";
 	end
 
 	module:set_status("info", "Not yet connected");
-	http.request(rest_url, {
+	http.request(get_url(st.stanza("meta", { type = "info", to = module.host, from = module.host })), {
 			method = "OPTIONS",
 		}, function (body, code, response)
 			if code == 0 then
-				return module:log_status("error", "Could not connect to callback URL %q: %s", rest_url, body);
-			else
+				module:log_status("error", "Could not connect to callback URL %q: %s", rest_url, body);
+			elseif code == 200 then
 				module:set_status("info", "Connected");
-			end
-			if code == 200 and response.headers.accept then
-				send_type = decide_type(response.headers.accept, supported_outputs);
-				module:log("debug", "Set 'rest_callback_content_type' = %q based on Accept header", send_type);
+				if response.headers.accept then
+					send_type = decide_type(response.headers.accept, supported_outputs);
+					module:log("debug", "Set 'rest_callback_content_type' = %q based on Accept header", send_type);
+				end
+			else
+				module:log_status("warn", "Unexpected response code %d from OPTIONS probe", code);
+				module:log("warn", "Endpoint said: %s", body);
 			end
 		end);
 
@@ -448,7 +467,7 @@
 		stanza = st.clone(stanza, true);
 
 		module:log("debug", "Sending[rest]: %s", stanza:top_tag());
-		http.request(rest_url, {
+		http.request(get_url(stanza), {
 				body = request_body,
 				headers = {
 					["Content-Type"] = send_type,
@@ -534,21 +553,19 @@
 		return true;
 	end
 
-	if module:get_host_type() == "component" then
-		module:hook("iq/bare", handle_stanza, -1);
-		module:hook("message/bare", handle_stanza, -1);
-		module:hook("presence/bare", handle_stanza, -1);
-		module:hook("iq/full", handle_stanza, -1);
-		module:hook("message/full", handle_stanza, -1);
-		module:hook("presence/full", handle_stanza, -1);
-		module:hook("iq/host", handle_stanza, -1);
-		module:hook("message/host", handle_stanza, -1);
-		module:hook("presence/host", handle_stanza, -1);
-	else
-		-- Don't override everything on normal VirtualHosts
-		module:hook("iq/host", handle_stanza, -1);
-		module:hook("message/host", handle_stanza, -1);
-		module:hook("presence/host", handle_stanza, -1);
+	local send_kinds = module:get_option_set("rest_callback_stanzas", { "message", "presence", "iq" });
+
+	local event_presets = {
+		-- Don't override everything on normal VirtualHosts by default
+		["local"] = { "host" },
+		-- Comonents get to handle all kinds of stanzas
+		["component"] = { "bare", "full", "host" },
+	};
+	local hook_events = module:get_option_set("rest_callback_events", event_presets[module:get_host_type()]);
+	for kind in send_kinds do
+		for event in hook_events do
+			module:hook(kind.."/"..event, handle_stanza, -1);
+		end
 	end
 end
 
--- a/mod_rest/res/openapi.yaml	Wed May 11 12:43:26 2022 +0200
+++ b/mod_rest/res/openapi.yaml	Wed May 11 12:44:32 2022 +0200
@@ -136,7 +136,7 @@
     get:
       tags:
       - query
-      summary: Query a message archive
+      summary: Query for external services (usually STUN and TURN)
       security:
       - basic: []
       - token: []
@@ -283,8 +283,8 @@
         idle_since:
           $ref: '#/components/schemas/idle_since'
 
-        join:
-          $ref: '#/components/schemas/join'
+        muc:
+          $ref: '#/components/schemas/muc'
 
         error:
           $ref: '#/components/schemas/error'
@@ -510,11 +510,37 @@
         namespace: urn:xmpp:message-correct:0
         x_single_attribute: id
 
-    join:
-      description: For joining Multi-User-Chats
-      type: boolean
-      enum:
-      - true
+    muc:
+      description: Multi-User-Chat related
+      type: object
+      xml:
+        name: x
+        namespace: http://jabber.org/protocol/muc
+      properties:
+        history:
+          type: object
+          properties:
+            maxchars:
+              type: integer
+              minimum: 0
+              xml:
+                attribute: true
+            maxstanzas:
+              type: integer
+              minimum: 0
+              xml:
+                attribute: true
+            seconds:
+              type: integer
+              minimum: 0
+              xml:
+                attribute: true
+            since:
+              type: string
+              format: date-time
+              xml:
+                attribute: true
+
 
     invite:
       type: object
--- a/mod_rest/res/schema-xmpp.json	Wed May 11 12:43:26 2022 +0200
+++ b/mod_rest/res/schema-xmpp.json	Wed May 11 12:44:32 2022 +0200
@@ -149,6 +149,28 @@
             "namespace" : "http://jabber.org/protocol/nick"
          }
       },
+      "payload" : {
+         "properties" : {
+            "data" : {
+               "format" : "json",
+               "type" : "string",
+               "xml" : {
+                  "text" : true
+               }
+            },
+            "datatype" : {
+               "type" : "string",
+               "xml" : {
+                  "attribute" : true
+               }
+            }
+         },
+         "title" : "XEP-0432: Simple JSON Messaging",
+         "type" : "object",
+         "xml" : {
+            "namespace" : "urn:xmpp:json-msg:0"
+         }
+      },
       "rsm" : {
          "properties" : {
             "after" : {
@@ -233,7 +255,7 @@
                      "items" : {
                         "properties" : {
                            "expires" : {
-                              "format" : "datetime",
+                              "format" : "date-time",
                               "type" : "string",
                               "xml" : {
                                  "attribute" : true
@@ -1056,6 +1078,48 @@
             "lang" : {
                "$ref" : "#/_common/lang"
             },
+            "muc" : {
+               "properties" : {
+                  "history" : {
+                     "properties" : {
+                        "maxchars" : {
+                           "minimum" : 0,
+                           "type" : "integer",
+                           "xml" : {
+                              "attribute" : true
+                           }
+                        },
+                        "maxstanzas" : {
+                           "minimum" : 0,
+                           "type" : "integer",
+                           "xml" : {
+                              "attribute" : true
+                           }
+                        },
+                        "seconds" : {
+                           "minimum" : 0,
+                           "type" : "integer",
+                           "xml" : {
+                              "attribute" : true
+                           }
+                        },
+                        "since" : {
+                           "format" : "date-time",
+                           "type" : "string",
+                           "xml" : {
+                              "attribute" : true
+                           }
+                        }
+                     },
+                     "type" : "object"
+                  }
+               },
+               "type" : "object",
+               "xml" : {
+                  "name" : "x",
+                  "namespace" : "http://jabber.org/protocol/muc"
+               }
+            },
             "nick" : {
                "$ref" : "#/_common/nick"
             },
--- a/mod_s2soutinjection/README.markdown	Wed May 11 12:43:26 2022 +0200
+++ b/mod_s2soutinjection/README.markdown	Wed May 11 12:44:32 2022 +0200
@@ -16,15 +16,16 @@
     "s2soutinjection";
 }
 
+-- targets must be IPs, not hostnames
 s2s_connect_overrides = {
     -- This one will use the default port, 5269
-    ["example.com"] = "xmpp.server.local";
+    ["example.com"] = "1.2.3.4";
 
     -- To set a different port:
-    ["another.example"] = { "non-standard-port.example", 9999 };
+    ["another.example"] = { "127.0.0.1", 9999 };
 }
 ```
 
 # Compatibility
 
-Requires 0.9.x or later.
+Requires 0.9.x or later. Tested on 0.12.0
--- a/mod_s2soutinjection/mod_s2soutinjection.lua	Wed May 11 12:43:26 2022 +0200
+++ b/mod_s2soutinjection/mod_s2soutinjection.lua	Wed May 11 12:44:32 2022 +0200
@@ -2,12 +2,69 @@
 local new_ip = require"util.ip".new_ip;
 local new_outgoing = require"core.s2smanager".new_outgoing;
 local bounce_sendq = module:depends"s2s".route_to_new_session.bounce_sendq;
-local s2sout = module:depends"s2s".route_to_new_session.s2sout;
+local initialize_filters = require "util.filters".initialize;
+local st = require "util.stanza";
+
+local portmanager = require "core.portmanager";
+
+local addclient = require "net.server".addclient;
+
+module:depends("s2s");
+
+local sessions = module:shared("sessions");
 
 local injected = module:get_option("s2s_connect_overrides");
 
-local function isip(addr)
-	return not not (addr and addr:match("^%d+%.%d+%.%d+%.%d+$") or addr:match("^[%x:]*:[%x:]-:[%x:]*$"));
+-- The proxy_listener handles connection while still connecting to the proxy,
+-- then it hands them over to the normal listener (in mod_s2s)
+local proxy_listener = { default_port = port, default_mode = "*a", default_interface = "*" };
+
+function proxy_listener.onconnect(conn)
+	local session = sessions[conn];
+
+	-- Now the real s2s listener can take over the connection.
+	local listener = portmanager.get_service("s2s").listener;
+
+	local w, log = conn.send, session.log;
+
+	local filter = initialize_filters(session);
+
+	session.version = 1;
+
+	session.sends2s = function (t)
+		log("debug", "sending (s2s over proxy): %s", (t.top_tag and t:top_tag()) or t:match("^[^>]*>?"));
+		if t.name then
+			t = filter("stanzas/out", t);
+		end
+		if t then
+			t = filter("bytes/out", tostring(t));
+			if t then
+				return conn:write(tostring(t));
+			end
+		end
+	end
+
+	session.open_stream = function ()
+		session.sends2s(st.stanza("stream:stream", {
+			xmlns='jabber:server', ["xmlns:db"]='jabber:server:dialback',
+			["xmlns:stream"]='http://etherx.jabber.org/streams',
+			from=session.from_host, to=session.to_host, version='1.0', ["xml:lang"]='en'}):top_tag());
+	end
+
+	conn.setlistener(conn, listener);
+
+	listener.register_outgoing(conn, session);
+
+	listener.onconnect(conn);
+end
+
+function proxy_listener.register_outgoing(conn, session)
+	session.direction = "outgoing";
+	sessions[conn] = session;
+end
+
+function proxy_listener.ondisconnect(conn, err)
+	sessions[conn]  = nil;
 end
 
 module:hook("route/remote", function(event)
@@ -16,34 +73,18 @@
 	if not inject then return end
 	log("debug", "opening a new outgoing connection for this stanza");
 	local host_session = new_outgoing(from_host, to_host);
-	host_session.version = 1;
 
 	-- Store in buffer
 	host_session.bounce_sendq = bounce_sendq;
 	host_session.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} };
 	log("debug", "stanza [%s] queued until connection complete", tostring(stanza.name));
 
-	local ip_hosts, srv_hosts = {}, {};
-	host_session.srv_hosts = srv_hosts;
-	host_session.srv_choice = 0;
+	local host, port = inject[1] or inject, tonumber(inject[2]) or 5269;
 
-	if type(inject) == "string" then inject = { inject } end
+	local conn = addclient(host, port, proxy_listener, "*a");
 
-	for _, item in ipairs(inject) do
-		local host, port = item[1] or item, tonumber(item[2]) or 5269;
-		if isip(host) then
-			ip_hosts[#ip_hosts+1] = { ip = new_ip(host), port = port }
-		else
-			srv_hosts[#srv_hosts+1] = { target = host, port = port }
-		end
-	end
-	if #ip_hosts > 0 then
-		host_session.ip_hosts = ip_hosts;
-		host_session.ip_choice = 0; -- Incremented by try_next_ip
-		s2sout.try_next_ip(host_session);
-		return true;
-	end
+	proxy_listener.register_outgoing(conn, host_session);
 
-	return s2sout.try_connect(host_session, host_session.srv_hosts[1].target, host_session.srv_hosts[1].port);
+	host_session.conn = conn;
+	return true;
 end, -2);
-