Changeset

6344:eb834f754f57 draft default tip

Merge update
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Fri, 18 Jul 2025 20:45:38 +0700 (8 days ago)
parents 6309:342f88e8d522 (current diff) 6343:6f4469d97349 (diff)
children
files mod_http_oauth2/README.md mod_http_oauth2/mod_http_oauth2.lua
diffstat 26 files changed, 845 insertions(+), 72 deletions(-) [+]
line wrap: on
line diff
--- a/mod_audit/mod_audit.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_audit/mod_audit.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -168,8 +168,8 @@
 function module.command(arg_)
 	local jid = require "util.jid";
 	local arg = require "util.argparse".parse(arg_, {
-		value_params = { "limit" };
-	 });
+		value_params = { limit = true };
+	});
 
 	module:log("debug", "arg = %q", arg);
 	local query_jid = jid.prep(arg[1]);
--- a/mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_cloud_notify_encrypted/mod_cloud_notify_encrypted.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -14,7 +14,7 @@
 	-- FIXME: luaossl does not expose the EVP_CTRL_GCM_GET_TAG API, so we append 16 NUL bytes
 	-- Siskin does not validate the tag anyway.
 	function crypto.aes_128_gcm_encrypt(key, iv, message)
-		return ciphers.new("AES-128-GCM"):encrypt(key, iv):final(message)..string.rep("\0", 16);
+		return ossl_ciphers.new("AES-128-GCM"):encrypt(key, iv):final(message)..string.rep("\0", 16);
 	end
 end
 
--- a/mod_conversejs/mod_conversejs.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_conversejs/mod_conversejs.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -74,6 +74,7 @@
 local function get_converse_options()
 	local user_options = module:get_option("conversejs_options");
 
+	local authentication = module:get_option_string("authentication");
 	local allow_registration = module:get_option_boolean("allow_registration", false);
 	local converse_options = {
 		-- Auto-detected connection endpoints
@@ -82,9 +83,9 @@
 		-- Since we provide those, XEP-0156 based auto-discovery should not be used
 		discover_connection_methods = false;
 		-- Authentication mode to use (normal or guest login)
-		authentication = module:get_option_string("authentication") == "anonymous" and "anonymous" or "login";
+		authentication = authentication == "anonymous" and "anonymous" or "login";
 		-- Host to connect to for anonymous access
-		jid = module.host;
+		jid = authentication == "anonymous" and module.host or nil;
 		-- Let users login with only username
 		default_domain = module.host;
 		domain_placeholder = module.host;
--- a/mod_groups_oidc/mod_groups_oidc.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_groups_oidc/mod_groups_oidc.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -1,6 +1,7 @@
 local array = require "util.array";
 
-module:add_item("openid-claim", "groups");
+module:add_item("openid-claim", { claim = "groups"; title = "User Groups";
+	description = "List of group memberships"; });
 
 local group_memberships = module:open_store("groups", "map");
 local function user_groups(username)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_connect/mod_http_connect.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -0,0 +1,135 @@
+-- This feature was added after Prosody 13.0
+--% requires: net-connect-filter
+
+local hashes = require "prosody.util.hashes";
+local server = require "prosody.net.server";
+local connect = require"prosody.net.connect".connect;
+local basic = require "prosody.net.resolvers.basic";
+local new_ip = require "prosody.util.ip".new_ip;
+
+local b64_decode = require "prosody.util.encodings".base64.decode;
+
+local proxy_secret = module:get_option_string("http_proxy_secret", require "prosody.util.id".long());
+
+local allow_private_ips = module:get_option_boolean("http_proxy_to_private_ips", false);
+local allow_all_ports = module:get_option_boolean("http_proxy_to_all_ports", false);
+
+local allowed_target_ports = module:get_option_set("http_proxy_to_ports", { "443", "5281", "5443", "7443" }) / tonumber;
+
+local sessions = {};
+
+local listeners = {};
+
+function listeners.onconnect(conn)
+	local event = sessions[conn];
+	local response = event.response;
+	response.status_code = 200;
+	response:send("");
+	response.conn:onwritable();
+	response.conn:setlistener(listeners, event);
+	server.link(conn, response.conn);
+	server.link(response.conn, conn);
+	response.conn = nil;
+end
+
+function listeners.onattach(conn, event)
+	sessions[conn] = event;
+end
+
+function listeners.onfail(event, err)
+	local response = event.response;
+	if assert(response) then
+		response.status_code = 500;
+		response:send(err);
+	end
+end
+
+function listeners.ondisconnect(conn, err) --luacheck: ignore 212/conn 212/err
+end
+
+local function is_permitted_target(conn_type, ip, port)
+	if not (allow_all_ports or allowed_target_ports:contains(tonumber(port))) then
+		module:log("warn", "Forbidding tunnel to %s:%d (forbidden port)", ip, port);
+		return false;
+	end
+	if not allow_private_ips then
+		local family = (conn_type:byte(-1, -1) == 54) and "IPv6" or "IPv4";
+		local parsed_ip = new_ip(ip, family);
+		if parsed_ip.private then
+			module:log("warn", "Forbidding tunnel to %s:%d (forbidden ip)", ip, port);
+			return false;
+		end
+	end
+	return true;
+end
+
+local function verify_auth(user, password)
+	local expiry = tonumber(user, 10);
+	if os.time() > expiry then
+		module:log("warn", "Attempt to use expired credentials");
+		return nil;
+	end
+	local expected_password = hashes.hmac_sha1(proxy_secret, user);
+	if hashes.equals(b64_decode(password), expected_password) then
+		return true;
+	end
+	module:log("warn", "Credential mismatch for %s: expected '%q' got '%q'", user, expected_password, password);
+end
+
+module:depends("http");
+module:provides("http", {
+	default_path = "/";
+	route = {
+		["CONNECT /*"] = function(event)
+			local request, response = event.request, event.response;
+			local host, port = request.url.scheme, request.url.path;
+			if port == "" then return 400 end
+
+			-- Auth check
+			local realm = host;
+			local headers = request.headers;
+			if not headers.proxy_authorization then
+				response.headers.proxy_authenticate = ("Basic realm=%q"):format(realm);
+				return 407
+			end
+			local user, password = b64_decode(headers.proxy_authorization:match"[^ ]*$"):match"([^:]*):(.*)";
+			if not verify_auth(user, password) then
+				response.headers.proxy_authenticate = ("Basic realm=%q"):format(realm);
+				return 407
+			end
+
+			local resolve = basic.new(host, port, "tcp", {
+				filter = is_permitted_target;
+			});
+			connect(resolve, listeners, nil, event)
+			return true;
+		end;
+	}
+});
+
+local http_url = module:http_url();
+local parsed_url = require "socket.url".parse(http_url);
+
+local proxy_host = parsed_url.host;
+local proxy_port = tonumber(parsed_url.port);
+
+if not proxy_port then
+	if parsed_url.scheme == "https" then
+		proxy_port = 443;
+	elseif parsed_url.scheme == "http" then
+		proxy_port = 80;
+	end
+end
+
+module:depends "external_services";
+
+module:add_item("external_service", {
+	type = "http";
+	transport = "tcp";
+	host = proxy_host;
+	port = proxy_port;
+
+	secret = proxy_secret;
+	algorithm = "turn";
+	ttl = 3600;
+});
--- a/mod_http_oauth2/README.md	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_http_oauth2/README.md	Fri Jul 18 20:45:38 2025 +0700
@@ -275,9 +275,9 @@
 
 OAuth supports "scopes" as a way to grant clients limited access.
 
-There are currently no standard scopes defined for XMPP. This is
-something that we intend to change, e.g. by definitions provided in a
-future XEP. This means that clients you authorize currently have to
+[XEP-0493: OAuth Client Login] describes using OAuth 2.0 / OpenID Connect with XMPP.
+This module does not yet support [the scopes defined](https://xmpp.org/extensions/xep-0493.html#oauth-scopes).
+This means that clients you authorize currently have to
 choose between unrestricted access to your account (including the
 ability to change your password and lock you out!) and zero access. So,
 for now, while using OAuth clients can prevent leaking your password to
@@ -292,7 +292,9 @@
 OpenID scopes such as `openid` and `profile` can be used for "Login
 with XMPP" without granting access to more than limited profile details.
 
+The `offline_access` scope must be requested to receive refresh tokens.
+
 ## Compatibility
 
-Requires Prosody trunk (April 2023), **not** compatible with Prosody 0.12 or
-earlier.
+Requires Prosody trunk (April 2023 or later) or Prosody 13.0,
+**not** compatible with Prosody 0.12 or earlier.
--- a/mod_http_oauth2/html/consent.html	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_http_oauth2/html/consent.html	Fri Jul 18 20:45:38 2025 +0700
@@ -35,8 +35,12 @@
 		<dd><a href="{client.policy_uri}">View policy</a></dd>}
 
 		<dt>Requested permissions</dt>
-		<dd>{scopes#
-			<input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked="" /><label class="scope" for="scope_{idx}">{item}</label>}
+		<dd>
+			<dl>{scopes#
+				<dt><input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item.claim}" checked="" />
+			<label class="scope" for="scope_{idx}">{item.title?{item.claim}}</label></dt>
+				{item.description&<dd>{item.description}</dd>}}
+			</ul>
 		</dd>
 	</dl>
 
--- a/mod_http_oauth2/mod_http_oauth2.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -28,6 +28,18 @@
 	end
 end
 
+local function array_contains(haystack, needle)
+	if not haystack then
+		return false
+	end
+	for i = 1, #haystack do
+		if haystack[i] == needle then
+			return true
+		end
+	end
+	return false
+end
+
 local function strict_url_parse(urlstr)
 	local url_parts = url.parse(urlstr);
 	if not url_parts then return url_parts; end
@@ -175,14 +187,23 @@
 end
 
 local openid_claims = set.new();
-module:add_item("openid-claim", "openid");
+
+module:add_item("openid-claim", { claim = "openid"; title = "OpenID";
+	description = "Tells the application your JID and when you authenticated."; });
+
+-- https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
+-- The "offline_access" scope grants access to refresh tokens
+module:add_item("openid-claim", { claim = "offline_access"; title = "Offline Access";
+	description = "Application may renew access without interaction."; });
 
 module:handle_items("openid-claim", function(event)
 	authorization_server_metadata = nil;
-	openid_claims:add(event.item);
+	openid_claims:add(event.item.claim or event.item);
 end, function()
 	authorization_server_metadata = nil;
-	openid_claims = set.new(module:get_host_items("openid-claim"));
+	openid_claims = set.new(array.new(module:get_host_items("openid-claim")):map(function(item)
+		return item.claim or item;
+	end));
 end, true);
 
 -- array -> array, array, array
@@ -265,6 +286,8 @@
 -- code to the user for them to copy-paste into the client, which can then
 -- continue as if it received it via redirect.
 local oob_uri = "urn:ietf:wg:oauth:2.0:oob";
+
+-- RFC 8628 OAuth 2.0 Device Authorization Grant
 local device_uri = "urn:ietf:params:oauth:grant-type:device_code";
 
 local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" });
@@ -292,6 +315,10 @@
 	};
 end
 
+local function may_issue_refresh_token(client, scope_string)
+	return array_contains(client.grant_types, "refresh_token") and array_contains(parse_scopes(scope_string), "offline_access");
+end
+
 local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info)
 	local token_data = { oauth2_scopes = scope_string, oauth2_client = nil };
 	if client then
@@ -316,7 +343,10 @@
 		end
 	end
 	-- in with the new refresh token
-	local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh");
+	local refresh_token;
+	if refresh_token_info ~= false and may_issue_refresh_token(client, scope_string) then
+		refresh_token = tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh");
+	end
 
 	if role == "xmpp" then
 		-- Special scope meaning the users default role.
@@ -349,7 +379,15 @@
 end
 
 local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string
-	if not query_redirect_uri then
+	if query_redirect_uri == device_uri and client.grant_types then
+		if array_contains(client.grant_types, device_uri) then
+			return query_redirect_uri;
+		end
+		-- Tried to use device authorization flow without registering it.
+		return;
+	elseif not client.redirect_uris then
+		return;
+	elseif not query_redirect_uri then
 		if #client.redirect_uris ~= 1 then
 			-- Client registered multiple URIs, it needs specify which one to use
 			return;
@@ -357,15 +395,6 @@
 		-- When only a single URI is registered, that's the default
 		return client.redirect_uris[1];
 	end
-	if query_redirect_uri == device_uri and client.grant_types then
-		for _, grant_type in ipairs(client.grant_types) do
-			if grant_type == device_uri then
-				return query_redirect_uri;
-			end
-		end
-		-- Tried to use device authorization flow without registering it.
-		return;
-	end
 	-- Verify the client-provided URI matches one previously registered
 	for _, redirect_uri in ipairs(client.redirect_uris) do
 		if query_redirect_uri == redirect_uri then
@@ -861,15 +890,6 @@
 	end
 end
 
-local function array_contains(haystack, needle)
-	for _, item in ipairs(haystack) do
-		if item == needle then
-			return true
-		end
-	end
-	return false
-end
-
 function handle_token_grant(event)
 	local credentials = get_request_credentials(event.request);
 
@@ -902,7 +922,7 @@
 
 
 	local grant_type = params.grant_type
-	if not array_contains(client.grant_types or { "authorization_code" }, grant_type) then
+	if not array_contains(client.grant_types, grant_type) then
 		return oauth_error("invalid_request", "'grant_type' not registered");
 	end
 
@@ -943,7 +963,7 @@
 	-- From this point we know that redirect_uri is safe to use
 
 	local response_type = params.response_type;
-	if not array_contains(client.response_types or { "code" }, response_type) then
+	if not array_contains(client.response_types, response_type) then
 		return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not registered"));
 	end
 	if not allowed_response_type_handlers:contains(response_type) then
@@ -988,6 +1008,10 @@
 		roles = user_assumable_roles(auth_state.user.username, roles);
 
 		if not prompt:contains("consent") then
+			if array_contains(scopes, "offline_access") then
+				-- MUST ensure that the prompt parameter contains consent
+				return error_response(request, redirect_uri, oauth_error("consent_required"));
+			end
 			local grants = tokens.get_user_grants(auth_state.user.username);
 			local matching_grant;
 			if grants then
@@ -1011,7 +1035,31 @@
 
 		else
 			-- Render consent page
-			return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
+			module:log("debug", "scopes=%q", scopes);
+			local scope_choices = array.new(module:get_host_items("openid-claim")):map(function(item)
+				if type(item) == "string" then
+					return { claim = item };
+				elseif type(item) == "table" and type(item.claim) == "string" then
+					return item;
+				end
+			end):filter(function (item)
+				if array_contains(scopes, item.claim) then
+					module:log("debug", "scopes contains %q", item);
+					return true;
+				else
+					module:log("debug", "scopes contains NO %q", item);
+					return false;
+				end
+			end);
+			for _, role in ipairs(roles) do
+				if role == "xmpp" then
+					scope_choices:push({ claim = role; title = "XMPP";
+						description = "Unlimited access to your account, including sending and receiving messages."; });
+				else
+					scope_choices:push({ claim = role; title = role, description = "Prosody Role" });
+				end
+			end
+			return render_page(templates.consent, { state = auth_state; client = client; scopes = scope_choices }, true);
 		end
 	elseif not auth_state.consent then
 		-- Notify client of rejection
@@ -1030,16 +1078,22 @@
 	params.scope = granted_scopes:concat(" ");
 
 	local user_jid = jid.join(auth_state.user.username, module.host);
-	local client_secret = make_client_secret(params.client_id);
-	local id_token_signer = jwt.new_signer("HS256", client_secret);
-	local id_token = id_token_signer({
-		iss = get_issuer();
-		sub = url.build({ scheme = "xmpp"; path = user_jid });
-		aud = params.client_id;
-		auth_time = auth_state.user.iat;
-		nonce = params.nonce;
-		amr = auth_state.user.amr; -- RFC 8176: Authentication Method Reference Values
-	});
+	local id_token;
+	-- https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.3.1.2.1
+	if array_contains(granted_scopes, "openid") then
+		local client_secret = make_client_secret(params.client_id);
+		local id_token_signer = jwt.new_signer("HS256", client_secret);
+		id_token = id_token_signer({
+			iss = get_issuer(); -- REQUIRED
+			sub = url.build({ scheme = "xmpp"; path = user_jid }); -- REQUIRED
+			aud = params.client_id; -- REQUIRED
+			-- exp REQUIRED, set by util.jwt
+			-- iat REQUIRED, set by util.jwt
+			auth_time = auth_state.user.iat; -- REQUIRED when Essential Claim, otherwise OPTIONAL
+			nonce = params.nonce;
+			amr = auth_state.user.amr; -- RFC 8176: Authentication Method Reference Values
+		});
+	end
 	local ret = response_handler(client, params, user_jid, id_token);
 	if errors.is_err(ret) then
 		return error_response(request, redirect_uri, ret);
@@ -1076,7 +1130,7 @@
 		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
 	end
 
-	if not set.new(client.grant_types):contains(device_uri) then
+	if not array_contains(client.grant_types, device_uri) then
 		return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant"));
 	end
 
@@ -1267,8 +1321,6 @@
 		-- These are shown to users in the template
 		"client_name";
 		"client_uri";
-		-- We need at least one redirect URI for things to work
-		"redirect_uris";
 	};
 	properties = {
 		redirect_uris = {
@@ -1285,8 +1337,10 @@
 					"http://localhost:8080/redirect";
 					"com.example.app:/redirect";
 					oob_uri;
-					device_uri;
 				};
+				["not"] = {
+					const = device_uri;
+				}
 			};
 		};
 		token_endpoint_auth_method = {
@@ -1458,9 +1512,12 @@
 		return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri");
 	end
 
-	if not client_metadata.application_type and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then
-		client_metadata.application_type = "native";
-		-- else defaults to "web"
+	if not client_metadata.application_type then
+		if client_metadata.redirect_uris and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then
+			client_metadata.application_type = "native";
+		elseif array_contains(client_metadata.grant_types, device_uri) then
+			client_metadata.application_type = "native";
+		end
 	end
 
 	-- Fill in default values
@@ -1477,9 +1534,11 @@
 		end
 	end
 
-	for _, redirect_uri in ipairs(client_metadata.redirect_uris) do
-		if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then
-			return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI.");
+	if client_metadata.redirect_uris then
+		for _, redirect_uri in ipairs(client_metadata.redirect_uris) do
+			if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then
+				return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI.");
+			end
 		end
 	end
 
@@ -1500,6 +1559,15 @@
 		return nil, oauth_error("invalid_client_metadata", "Disallowed 'response_types' specified");
 	end
 
+
+	if not client_metadata.redirect_uris then
+		if grant_types:contains("authorization_code") then
+			return nil, oauth_error("invalid_client_metadata", "The 'authorization_code' grant requires 'redirect_uris' to be present.");
+		elseif grant_types:contains("implicit") then
+			return nil, oauth_error("invalid_client_metadata", "The 'implicit' grant requires 'redirect_uris' to be present.");
+		end
+	end
+
 	if grant_types:contains("authorization_code") and not response_types:contains("code") then
 		return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'");
 	elseif grant_types:contains("implicit") and not response_types:contains("token") then
@@ -1513,6 +1581,8 @@
 		-- Not needed for public clients without a secret, but those are expected
 		-- to be uncommon since they can only do the insecure implicit flow.
 		client_metadata.nonce = id.short();
+	elseif grant_types ~= set.new({ "implicit" }) then
+		return nil, oauth_error("invalid_client_metadata", "A 'token_endpoint_auth_method' value of 'none' only works with the 'implicit' grant");
 	end
 
 	-- Do we want to keep everything?
@@ -1694,8 +1764,7 @@
 		token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil;
 		jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata
 		registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil;
-		scopes_supported = usermanager.get_all_roles
-			and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items()));
+		scopes_supported = array({ "xmpp" }):append(array(it.keys(usermanager.get_all_roles(module.host)))):append(array(openid_claims:items()));
 		response_types_supported = array(it.keys(response_type_handlers));
 		response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
 		grant_types_supported = array(it.keys(grant_type_handlers));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_presence/README.md	Fri Jul 18 20:45:38 2025 +0700
@@ -0,0 +1,62 @@
+---
+summary: JID presence and information through HTTP
+...
+
+This module provides a web interface for viewing the status, avatar, and information of a user or MUC.
+
+# Configuration
+
+The module `http_presence` can be enabled under a VirtualHost and/or a MUC component, providing web details for JIDs under each respectively. You should not enable this module under other components.
+
+  Name                   Description                                         Type   Default value
+  ---------------------- --------------------------------------------------- -------- ---------------
+  presence_http_path     presence path under Prosody's http host             string   "/presence"
+  presence_resource_path the path to the directory that stores assets        string   "resources"
+
+# URI
+
+To access a JIDs presence and information, use the following URI format:
+```
+https://<http_host>:5281/presence/<name>/<format>
+```
+
+  Format       User Muc Description
+  ------------ ---- --- -------------------------------------------------------------------------
+  full         Yes  Yes (Default) Provides a full HTML overview that can be embedded in webpages.
+  name         No   Yes Returns MUC title or name. If empty, returns JID.
+  nickname     Yes  No  Returns user nickname. PEP vCard4 must be set to public.
+  status       Yes  Yes Returns status of JID. Returns "muc" on MUCs.
+  message      Yes  No  Returns status message of user.
+  description  No   Yes Returns Full MUC description.
+  status-icon  Yes  Yes Returns status icon from resources. Returns "muc.png" on MUCs.
+  avatar       Yes  Yes Returns the users PEP avatar or MUC vCard avatar.
+  users        No   Yes Returns the amount of users in a MUC.
+
+For example, you can query the description of `support@muc.example.com` with this URL:
+```
+https://muc.example.com:5281/presence/support/description
+```
+
+# Resources
+
+Under the resource path should be PNG icons and a style.css which are all customizable.
+
+  Filename      Description
+  ------------- ---------------------------------------------------
+  style.css     Stylesheet used for full mode
+  avatar.png    Default avatar provided if the JID has no avatar
+  away.png      User "Away" status
+  chat.png      User "Chatty" or "Free To Chat" status
+  dnd.png       User "Do Not Disturb" status
+  muc.png       Status icon for MUC.
+  offline.png   User "Offline" status
+  online.png    User "Online" status
+  xa.png        User "Extended Away" or "Not Available" status
+
+Compatibility
+=============
+
+  version   note
+  --------- ---------------------------------------------------------------------------
+  13        Works
+  0.12      Might work
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_presence/mod_http_presence.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -0,0 +1,385 @@
+local mod_pep = module:depends("pep");
+module:depends("http");
+
+local storagemanager = require "core.storagemanager";
+local usermanager = require "core.usermanager";
+local stanza = require "util.stanza".stanza;
+local deserialize = require "util.stanza".deserialize;
+local base64_decode = require "util.encodings".base64.decode;
+local base64_encode = require "util.encodings".base64.encode;
+local http = require "net.http";
+local jid = require "util.jid";
+
+function get_user_presence(bare_jid)
+	local host = jid.host(bare_jid);
+	local sessions = prosody.hosts[host] and prosody.hosts[host].sessions[jid.node(bare_jid)];
+	if not sessions then
+		return { status = "offline", message = nil };
+	end
+	
+	local highest_priority_session = nil;
+	local highest_priority = -math.huge;
+	
+	for resource, session in pairs(sessions.sessions) do
+		if session.presence then
+			local priority = session.priority or 0;
+			if priority > highest_priority then
+				highest_priority = priority;
+				highest_priority_session = session;
+			end
+		end
+	end
+
+	if not highest_priority_session then
+		return { status = "offline", message = nil };
+	end
+
+	local presence = highest_priority_session.presence;
+	return {
+		status = presence and (presence:get_child("show") and presence:get_child("show"):get_text() or "online") or "offline",
+		message = presence and presence:get_child("status") and presence:get_child("status"):get_text() or nil
+	};
+end
+
+function get_user_avatar(bare_jid)
+	local pep_service = mod_pep.get_pep_service(jid.node(bare_jid));
+	if not pep_service then
+		module:log("error", "PEP storage not available");
+		return nil;
+	end
+	
+	local meta_ok, hash, meta = pep_service:get_last_item("urn:xmpp:avatar:metadata", module.host);
+	if not meta_ok or not hash then
+		module:log("debug", "Failed to get avatar metadata for %s: %s", bare_jid, "Not OK");
+		return nil;
+	end
+	
+	local data_ok, data_hash, data = pep_service:get_last_item("urn:xmpp:avatar:data", module.host, hash);
+	local data_err = nil;
+	if not data_ok then
+		data_err = "Not OK";
+	elseif data_hash ~= hash then
+		data_err = "Hash does not match";
+	elseif type(data) ~= "table" then
+		data_err = "Data of type table";
+	end
+	if data_err then
+		module:log("debug", "Failed to get avatar data for %s, hash %s: %s", bare_jid, hash, data_err);
+		return nil;
+	end
+	local info = meta.tags[1]:get_child("info");
+	if not info then
+		module:log("debug", "Missing avatar info for %s, hash %s", bare_jid, hash);
+		return nil;
+	end
+	return info and info.attr.type or "application/octet-stream", data[1]:get_text();
+end
+
+function get_user_nickname(bare_jid)
+	local pep_service = mod_pep.get_pep_service(jid.node(bare_jid));
+	if not pep_service then
+		module:log("error", "PEP storage not available");
+		return nil;
+	end
+	
+	local ok, nick, nick_item = pep_service:get_last_item("urn:xmpp:vcard4", module.host);
+	if not ok then
+		module:log("debug", "Failed to get nick for %s: %s", bare_jid, "Not OK");
+		return nil;
+	end
+	
+	if nick_item and nick_item.tags and nick_item.tags[1] and nick_item.tags[1].tags then
+		for _, tag in ipairs(nick_item.tags[1].tags) do
+			if tag.name == "nickname" and tag.tags and tag.tags[1] and tag.tags[1][1] then
+				nickname = tag.tags[1][1];
+				module:log("debug", "Nickname found for JID %s: %s", bare_jid, nickname);
+				return nickname;
+			end
+		end
+	else
+		module:log("debug", "Invalid vCard4 item structure for JID %s", bare_jid);
+		return nil;
+	end
+	
+	module:log("debug", "No <nickname> element in vCard4 for JID %s", bare_jid);
+	return jid.node(bare_jid);
+end
+
+function get_muc_avatar(bare_jid)
+	local node = jid.node(bare_jid);
+	local vcard_store = storagemanager.open(module.host, "vcard_muc")
+	if not vcard_store then
+		module:log("error", "MUC vCard store not available for host: %s", module.host);
+		return nil, nil, "MUC vCard store not available";
+	end
+	
+	local vcard_data, err = vcard_store:get(node);
+	if not vcard_data then
+		module:log("debug", "No vCard data for MUC %s: %s", bare_jid, err or "No data");
+		return nil, nil, err or "No vCard data";
+	end
+
+	local vcard = deserialize(vcard_data);
+	if not vcard then
+		module:log("debug", "Failed to parse vCard for MUC %s", bare_jid);
+		return nil, nil, "Failed to parse vCard";
+	end
+
+	local photo = vcard:get_child("PHOTO");
+	if not photo then
+		module:log("debug", "No <PHOTO> element in vCard for MUC %s", bare_jid);
+		return nil, nil, "No photo element";
+	end
+
+	local content_type = photo:get_child_text("TYPE") or "application/octet-stream";
+	local avatar_data = photo:get_child_text("BINVAL");
+	if not avatar_data then
+		module:log("debug", "No <BINVAL> in <PHOTO> for MUC %s", bare_jid);
+		return nil, nil, "No avatar data";
+	end
+
+	module:log("debug", "MUC avatar found for JID %s: type=%s, data=%s",
+			   bare_jid, content_type, avatar_data:sub(1, 20) .. "...");
+	return content_type, avatar_data, nil;
+end
+
+function get_muc_info(bare_jid)
+	local node = jid.node(bare_jid);
+	local muc_store = storagemanager.open(module.host, "config");
+	if not muc_store then
+		module:log("error", "MUC config store not available for host: %s", module.host);
+		return nil, nil, "MUC config store not available";
+	end
+	
+	local config_data, err = muc_store:get(node);
+	if not config_data then
+		module:log("debug", "No config data for JID %s: %s", bare_jid, err or "No data");
+		return nil, nil, err or "No config data";
+	end
+	
+	local muc_name = config_data._data and config_data._data.name;
+	local muc_description = config_data._data and config_data._data.description;
+	if not muc_name and not muc_description then
+		module:log("debug", "No name or description in config for JID %s", bare_jid);
+		return nil, nil, "No name or description";
+	end
+
+	module:log("debug", "MUC info for JID %s: name=%s, desc=%s", bare_jid, muc_name, muc_description);
+	return muc_name, muc_description, nil;
+end
+
+function get_muc_users(bare_jid)
+	local component = hosts[module.host];
+	if not component then
+		module:log("error", "No component found for host: %s", module.host);
+		return nil, "No MUC component found";
+	end
+	local muc = component.modules.muc;
+	if not muc then
+		module:log("error", "MUC module not loaded for host: %s", module.host);
+		return nil, "MUC module not loaded";
+	end
+	local room = muc.get_room_from_jid(bare_jid);
+	if not room then
+		module:log("error", "Room %s does not exist", bare_jid);
+		return nil, "Room does not exist";
+	end
+	local count = 0;
+	for _ in room:each_occupant() do
+		count = count + 1;
+	end
+	
+	module:log("debug", "Room %s has %d occupants", bare_jid, count);
+	return count, nil;
+end
+
+function serve_user(response, format, user_jid)
+	local presence = get_user_presence(user_jid);
+	local nickname = get_user_nickname(user_jid) or user_jid;
+	
+	local status = presence.status or "offline";
+	local message = presence.message or "";
+	
+	if not format or format == "" or format == "full" then
+		response.headers["Content-Type"] = "text/html";
+		return response:send(
+			[[<!DOCTYPE html>]]..
+			tostring(
+				stanza("html")
+					:tag("head")
+						:tag("title"):text(nickname):up()
+						:tag("link", { rel = "stylesheet", href = "data:text/css;base64,"..base64_encode(request_resource("style.css")) })
+						:up()
+					:tag("body")
+						:tag("table", { width = "100%" })
+							:tag("colgroup")
+								:tag("col", { width = "64px" }):up()
+								:tag("col"):up()
+								:up()
+							:tag("tr")
+								:tag("td", { rowspan = "3", valign = "top" })
+									:tag("img", { id = "avatar", src = "./avatar", width = "64" })
+									:up()
+								:tag("td")
+									:tag("img", { id = "status-icon", src = "./status-icon", title = status, alt = "("..status..")" }):up()
+									:tag("b", { id = "nickname"}):text(" "..nickname):up()
+									:up()
+								:up()
+							:tag("tr")
+								:tag("td", { id = "msg-cell" }):text(message):up()
+								:up()
+							:tag("tr")
+								:tag("td", { id = "jid-cell" })
+									:tag("i")
+										:tag("a", { href = "xmpp:"..user_jid.."?add" }):text(user_jid):up()
+										:up()
+									:up()
+								:up()
+			)
+		);
+	elseif format == "nickname" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(nickname);
+	elseif format == "status" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(status);
+	elseif format == "message" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(message);
+	elseif format == "status-icon" then
+		response.headers["Content-Type"] = "image/png";
+		local status_resource = request_resource(status..".png");
+		if not status_resource then
+			return response:send(request_resource("offline.png"));
+		end
+		return response:send(status_resource);
+	elseif format == "avatar" then
+		local avatar_mime, avatar_data = get_user_avatar(user_jid);
+		if not avatar_mime or not avatar_data then
+			response.headers["Content-Type"] = "image/png";
+			return response:send(request_resource("avatar.png"));
+		end
+		response.headers["Content-Type"] = avatar_mime;
+		return response:send(base64_decode(avatar_data));
+	else
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(status..": "..message);
+	end
+end
+
+function serve_muc(response, format, muc_jid)
+	local muc_name, muc_desc, err = get_muc_info(muc_jid);
+	local muc_users, _ = get_muc_users(muc_jid);
+	
+	if not format or format == "" or format == "full" then
+		response.headers["Content-Type"] = "text/html";
+		return response:send(
+			[[<!DOCTYPE html>]]..
+			tostring(
+				stanza("html")
+					:tag("head")
+						:tag("title"):text(muc_name or muc_jid):up()
+						:tag("link", { rel = "stylesheet", href = "data:text/css;base64,"..base64_encode(request_resource("style.css")) })
+						:up()
+					:tag("body")
+						:tag("table", { width = "100%" })
+							:tag("colgroup")
+								:tag("col", { width = "64px" }):up()
+								:tag("col"):up()
+								:up()
+							:tag("tr")
+								:tag("td", { rowspan = "3", valign = "top" })
+									:tag("img", { id = "avatar", src = "./avatar", width = "64" })
+									:up()
+								:tag("td")
+									:tag("img", { id = "status-icon", src = "./status-icon", title = "muc", alt = "(muc)" }):up()
+									:tag("b", { id = "nickname" }):text(" "..(muc_name or muc_jid)):up()
+									:tag("a", { id = "muc-users" }):text(" ("..muc_users.." users)"):up()
+									:up()
+								:up()
+							:tag("tr")
+								:tag("td", { id = "msg-cell" }):text(muc_desc):up()
+								:up()
+							:tag("tr")
+								:tag("td", { id = "jid-cell" })
+									:tag("i")
+										:tag("a", { href = "xmpp:"..muc_jid.."?join" }):text(muc_jid):up()
+										:up()
+									:up()
+								:up()
+			)
+		);
+	elseif format == "users" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(muc_users.." users");
+	elseif format == "name" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(muc_name);
+	elseif format == "status" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send("muc");
+	elseif format == "description" then
+		response.headers["Content-Type"] = "text/plain";
+		return response:send(muc_desc);
+	elseif format == "status-icon" then
+		response.headers["Content-Type"] = "image/png";
+		return response:send(request_resource("muc.png"));
+	elseif format == "avatar" then
+		local avatar_mime, avatar_data = get_muc_avatar(muc_jid);
+		if not avatar_mime or not avatar_data then
+			response.headers["Content-Type"] = "image/png";
+			return response:send(request_resource("avatar.png"));
+		end
+		response.headers["Content-Type"] = avatar_mime;
+		return response:send(base64_decode(avatar_data));
+	else
+		response.headers["Content-Type"] = "text/plain";
+		return response:send((muc_name or muc_jid)..": "..(muc_desc or ""));
+	end
+end
+
+function request_resource(name)
+	local resource_path = module:get_option_string("presence_resource_path", "resources");
+	local i, err = module:load_resource(resource_path.."/"..name);
+	if not i then
+		module:log("warn", "Failed to open resource file %s: %s", resource_path.."/"..name, err);
+		return "";
+	end
+	return i:read("*a");
+end
+
+function handle_request(event, path)
+	local request = event.request;
+	local response = event.response;
+	local name, format = path:match("^([%w-_\\.]+)/(.*)$");
+	module:log("debug", "loading format '%s' for jid %s", format or "standard", name);
+	
+	if not name then
+		response.status_code = 404;
+		return response:send("Missing JID");
+	end
+	
+	local bare_jid = jid.join(name, module.host, nil);
+	local component = hosts[module.host];
+	if component.type == "component" and component.modules.muc then
+		local muc = component.modules.muc;
+		if not muc.get_room_from_jid(bare_jid) then
+			response.status_code = 404;
+			return response:send("MUC does not exist");
+		end
+		return serve_muc(response, format or "full", bare_jid);
+	else
+		if not usermanager.user_exists(name, module.host) then
+			response.status_code = 404;
+			return response:send("User does not exist");
+		end
+		return serve_user(response, format or "full", bare_jid);
+	end
+end
+
+module:provides("http", {
+	default_path = module:get_option_string("presence_http_path", "/presence");
+	route = {
+		["GET /*"] = handle_request;
+	};
+});
\ No newline at end of file
Binary file mod_http_presence/resources/avatar.png has changed
Binary file mod_http_presence/resources/away.png has changed
Binary file mod_http_presence/resources/chat.png has changed
Binary file mod_http_presence/resources/dnd.png has changed
Binary file mod_http_presence/resources/muc.png has changed
Binary file mod_http_presence/resources/offline.png has changed
Binary file mod_http_presence/resources/online.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_presence/resources/style.css	Fri Jul 18 20:45:38 2025 +0700
@@ -0,0 +1,7 @@
+html { height: 100%; }
+body {
+	background: linear-gradient(#FCFCFC, #EEEEEE);
+	background-repeat: no-repeat;
+}
+#msg-cell { white-space: pre-wrap; word-wrap: break-word; }
+#jid-cell *:link, #jid-cell *:visited, #muc-users { color: #666666; }
\ No newline at end of file
Binary file mod_http_presence/resources/xa.png has changed
--- a/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -1,10 +1,14 @@
 -- Provide OpenID UserInfo data to mod_http_oauth2
 -- Alternatively, separate module for the whole HTTP endpoint?
 --
-module:add_item("openid-claim", "address");
-module:add_item("openid-claim", "email");
-module:add_item("openid-claim", "phone");
-module:add_item("openid-claim", "profile");
+module:add_item("openid-claim", { claim = "address"; title = "Address";
+	description = "Address details, if any, given in your user profile."; });
+module:add_item("openid-claim", { claim = "email"; title = "Email";
+	description = "Email address entered in your user profile." });
+module:add_item("openid-claim", { claim = "phone"; title = "Phone Number";
+	description = "Phone number entered in your user profile."; });
+module:add_item("openid-claim", { claim = "profile"; title = "Profile";
+	description = "Complete profile details" });
 
 local mod_pep = module:depends "pep";
 
--- a/mod_pubsub_feeds/README.md	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_pubsub_feeds/README.md	Fri Jul 18 20:45:38 2025 +0700
@@ -39,7 +39,7 @@
 
 This module also implements [WebSub](https://www.w3.org/TR/websub/),
 formerly known as
-[PubSubHubbub](http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html).
+[PubSubHubbub](http://web.archive.org/web/20150705085301/http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html).
 This allows "feed hubs" to instantly push feed updates to subscribers.
 
 This may be removed in the future since it does not seem to be oft used
--- a/mod_pubsub_feeds/mod_pubsub_feeds.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_pubsub_feeds/mod_pubsub_feeds.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -228,7 +228,7 @@
 		if query["hub.mode"] == "unsubscribe" then
 			-- Unsubscribe from unknown feed
 			module:log("debug", "Unsubscribe from unknown feed %s -- %s", query["hub.topic"], formencode(query));
-			return query["hub.challenge"];
+			return { headers = { content_type = "text/plain" }; body = query["hub.challenge"] };
 		end
 		module:log("debug", "Push for unknown feed %s -- %s", query["hub.topic"], formencode(query));
 		return 404;
@@ -254,7 +254,7 @@
 			if lease_seconds then
 				feed.lease_expires = time() + lease_seconds - refresh_interval * 2;
 			end
-			return query["hub.challenge"];
+			return { headers = { content_type = "text/plain" }; body = query["hub.challenge"] };
 		end
 		return 400;
 	elseif method == "POST" then
--- a/mod_push2/mod_push2.lua	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_push2/mod_push2.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -152,6 +152,10 @@
 		if stanza:get_child("propose", "urn:xmpp:jingle-message:0") then
 			return true, "jingle call"
 		end
+
+		if stanza:get_child("retract", "urn:xmpp:jingle-message:0") then
+			return true, "jingle call retract"
+		end
 	end
 end
 
@@ -381,7 +385,7 @@
 				to_host = to_host or module.host
 
 				-- If another session has recent activity within configured grace period, don't send push
-				if does_match and match.grace and to_host == module.host and host_sessions[to_user] then
+				if does_match and match.grace and not is_voip(stanza) and to_host == module.host and host_sessions[to_user] then
 					local now = os_time()
 					for _, session in pairs(host_sessions[to_user].sessions) do
 						if session.last_activity and session.push_registration_id ~= push_registration_id and (now - session.last_activity) < match.grace then
--- a/mod_rest/example/rest.sh	Sun Jun 15 01:08:46 2025 +0700
+++ b/mod_rest/example/rest.sh	Fri Jul 18 20:45:38 2025 +0700
@@ -18,6 +18,7 @@
 # Settings
 HOST=""
 DOMAIN=""
+PRINT="b"
 
 SESSION="session-read-only"
 
@@ -26,7 +27,7 @@
 	source "${XDG_CONFIG_HOME:-$HOME/.config}/restrc"
 
 	if [ -z "${SCOPE:-}" ]; then
-		SCOPE="openid xmpp"
+		SCOPE="openid offline_access xmpp"
 	fi
 fi
 
@@ -35,8 +36,21 @@
 	exit 1
 fi
 
-while getopts 'r:h:' flag; do
+while getopts 'vr:h:' flag; do
 	case "$flag" in
+		v)
+			case "$PRINT" in
+				b)
+					PRINT="Bb"
+					;;
+				Bb)
+					PRINT="HBhb"
+					;;
+				HBhb)
+					PRINT="HBhbm"
+					;;
+			esac
+			;;
 		r)
 			case "$OPTARG" in
 				o)
@@ -88,4 +102,4 @@
 	shift 1
 fi
 
-https --check-status -p b --"$SESSION" rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
+https --check-status -p "$PRINT" --"$SESSION" rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_version_spoofed/README.md	Fri Jul 18 20:45:38 2025 +0700
@@ -0,0 +1,25 @@
+---
+summary: Server version spoofer
+...
+
+This module is a fork of the built-in mod_version that adds spoofing options. Please do not use this module to mess with services that provide statistics and information. Instead, contact the hosts of such services and request blacklisting.
+
+# Configuration
+
+  Name                   Description                                           Type      Default value
+  ---------------------- --------------------------------------------------- -------- ---------------
+  server\_name           the reported name of the server software            string   "Prosody"
+  server\_version        the reported version of the server software         string   `prosody.version`
+  server\_platform       the reported platform of the server software        string   nil
+
+This replaces mod_version, so you must disable mod_version when enabling or the modules might conflict. Unconfigured, this module acts the same as mod_version.
+
+As a tip if you want complete spoofing, you should use the `name` option under your VirtualHost and components to hide mentions of Prosody.
+
+Compatibility
+=============
+
+  version   note
+  --------- ---------------------------------------------------------------------------
+  13        Should work
+  0.12      Works
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_version_spoofed/mod_version_spoofed.lua	Fri Jul 18 20:45:38 2025 +0700
@@ -0,0 +1,60 @@
+-- Prosody IM
+-- Copyright (C) 2025-2025 Nicholas George
+-- Original mod_version copyright
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- This is a fork of mod_version that implements the ability to spoof server information.
+-- This should replace mod_version in the modules_enabled list. Do not load both as they
+-- will conflict.
+
+local st = require "util.stanza";
+
+module:add_feature("jabber:iq:version");
+
+local query = st.stanza("query", {xmlns = "jabber:iq:version"})
+	:text_tag("name", module:get_option_string("server_name", "Prosody"))
+	:text_tag("version", module:get_option_string("server_version", prosody.version));
+
+if not module:get_option_boolean("hide_os_type") then
+	local platform;
+	local spoofed_platform = module:get_option_string("server_platform", nil);
+	if not spoofed_platform then
+		if os.getenv("WINDIR") then
+			platform = "Windows";
+		else
+			local os_version_command = module:get_option_string("os_version_command");
+			local ok, pposix = pcall(require, "prosody.util.pposix");
+			if not os_version_command and (ok and pposix and pposix.uname) then
+				local uname, err = pposix.uname();
+				if not uname then
+					module:log("debug", "Could not retrieve OS name: %s", err);
+				else
+					platform = uname.sysname;
+				end
+			end
+			if not platform then
+				local uname = io.popen(os_version_command or "uname");
+				if uname then
+					platform = uname:read("*a");
+				end
+				uname:close();
+			end
+		end
+		if platform then
+			platform = platform:match("^%s*(.-)%s*$") or platform;
+			query:text_tag("os", platform);
+		end
+	else
+		query:text_tag("os", spoofed_platform);
+	end
+end
+
+module:hook("iq-get/host/jabber:iq:version:query", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	origin.send(st.reply(stanza):add_child(query));
+	return true;
+end);
\ No newline at end of file