Merge 0.11->0.12 0.12 0.12.2
authorKim Alvefur <zash@zash.se>
Mon, 12 Dec 2022 07:07:13 +0100
branch0.12
changeset 12803 3784a8ce0596
parent 12800 d7853bbc88ea (diff)
parent 12802 c4b1b5cbc20b (current diff)
child 12805 ebd6b4d8bf04
Merge 0.11->0.12
.hgtags
util/stanza.lua
--- a/.busted	Mon Dec 12 07:03:31 2022 +0100
+++ b/.busted	Mon Dec 12 07:07:13 2022 +0100
@@ -2,7 +2,7 @@
   _all = {
   },
   default = {
-    ["exclude-tags"] = "mod_bosh,storage";
+    ["exclude-tags"] = "mod_bosh,storage,SLOW";
   };
   bosh = {
     tags = "mod_bosh";
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.editorconfig	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,19 @@
+root = true
+
+[*]
+end_of_line = lf
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[CHANGES]
+indent_size = 4
+indent_style = space
+
+[configure]
+indent_size = 3
+indent_style = space
+
+[**.xml]
+indent_size = 2
+indent_style = space
--- a/.hgtags	Mon Dec 12 07:03:31 2022 +0100
+++ b/.hgtags	Mon Dec 12 07:07:13 2022 +0100
@@ -81,4 +81,6 @@
 76b4e3f12b53fedae96402d87fa9ee79e704ce5e 0.11.11
 783056b4e4480389d0e27883289b1bfef57e4729 0.11.12
 ebeb4d959fb3fdbc9235fd42e16a33f3f78241a8 0.11.13
+50fcd387948263335ca98dc98de2a3087b543f8b 0.12.0
+252ed01896dd815700593b86834c776d0fef828d 0.12.1
 be09ac8300a7bde4e81f7cc4e4ee5b0745ab14b7 0.11.14
--- a/.luacheckrc	Mon Dec 12 07:03:31 2022 +0100
+++ b/.luacheckrc	Mon Dec 12 07:07:13 2022 +0100
@@ -1,7 +1,8 @@
 cache = true
 codes = true
-ignore = { "411/err", "421/err", "411/ok", "421/ok", "211/_ENV", "431/log", "143/table", "113/unpack" }
+ignore = { "411/err", "421/err", "411/ok", "421/ok", "211/_ENV", "431/log", "214", "581" }
 
+std = "lua53c"
 max_line_length = 150
 
 read_globals = {
@@ -11,6 +12,9 @@
 files["prosody"] = {
 	allow_defined_top = true;
 	module = true;
+	globals = {
+		"prosody";
+	}
 }
 files["prosodyctl"] = {
 	allow_defined_top = true;
@@ -25,6 +29,12 @@
 	-- Ignore unwrapped license text
 	max_comment_line_length = false;
 }
+files["util/jsonschema.lua"] = {
+	ignore = { "211" };
+}
+files["util/datamapper.lua"] = {
+	ignore = { "211" };
+}
 files["plugins/"] = {
 	module = true;
 	allow_defined_top = true;
@@ -33,12 +43,12 @@
 		"module.name",
 		"module.host",
 		"module._log",
-		"module.log",
 		"module.event_handlers",
 		"module.reloading",
 		"module.saved_state",
 		"module.global",
 		"module.path",
+		"module.items",
 
 		-- Module API
 		"module.add_extension",
@@ -46,6 +56,9 @@
 		"module.add_identity",
 		"module.add_item",
 		"module.add_timer",
+		"module.weekly",
+		"module.daily",
+		"module.hourly",
 		"module.broadcast",
 		"module.context",
 		"module.depends",
@@ -64,22 +77,25 @@
 		"module.get_option_scalar",
 		"module.get_option_set",
 		"module.get_option_string",
+		"module.get_status",
 		"module.handle_items",
 		"module.hook",
 		"module.hook_global",
 		"module.hook_object_event",
 		"module.hook_tag",
 		"module.load_resource",
+		"module.log",
+		"module.log_status",
 		"module.measure",
-		"module.measure_event",
-		"module.measure_global_event",
-		"module.measure_object_event",
+		"module.metric",
 		"module.open_store",
 		"module.provides",
 		"module.remove_item",
 		"module.require",
 		"module.send",
+		"module.send_iq",
 		"module.set_global",
+		"module.set_status",
 		"module.shared",
 		"module.unhook",
 		"module.unhook_object_event",
@@ -123,46 +139,39 @@
 if os.getenv("PROSODY_STRICT_LINT") ~= "1" then
 	-- These files have not yet been brought up to standard
 	-- Do not add more files here, but do help us fix these!
-	unused_secondaries = false
 
 	local exclude_files = {
-	"doc/net.server.lua";
+		"doc/net.server.lua";
 
-	"fallbacks/bit.lua";
-	"fallbacks/lxp.lua";
+		"fallbacks/bit.lua";
+		"fallbacks/lxp.lua";
 
-	"net/adns.lua";
-	"net/cqueues.lua";
-	"net/dns.lua";
-	"net/server_select.lua";
+		"net/dns.lua";
+		"net/server_select.lua";
 
-	"plugins/mod_storage_sql1.lua";
+		"util/vcard.lua";
+
+		"plugins/mod_storage_sql1.lua";
 
-	"spec/core_configmanager_spec.lua";
-	"spec/core_moduleapi_spec.lua";
-	"spec/net_http_parser_spec.lua";
-	"spec/util_events_spec.lua";
-	"spec/util_http_spec.lua";
-	"spec/util_ip_spec.lua";
-	"spec/util_multitable_spec.lua";
-	"spec/util_rfc6724_spec.lua";
-	"spec/util_throttle_spec.lua";
-	"spec/util_xmppstream_spec.lua";
+		"spec/core_moduleapi_spec.lua";
+		"spec/util_http_spec.lua";
+		"spec/util_ip_spec.lua";
+		"spec/util_multitable_spec.lua";
+		"spec/util_rfc6724_spec.lua";
+		"spec/util_throttle_spec.lua";
 
-	"tools/ejabberd2prosody.lua";
-	"tools/ejabberdsql2prosody.lua";
-	"tools/erlparse.lua";
-	"tools/jabberd14sql2prosody.lua";
-	"tools/migration/migrator.cfg.lua";
-	"tools/migration/migrator/jabberd14.lua";
-	"tools/migration/migrator/mtools.lua";
-	"tools/migration/migrator/prosody_files.lua";
-	"tools/migration/migrator/prosody_sql.lua";
-	"tools/migration/prosody-migrator.lua";
-	"tools/openfire2prosody.lua";
-	"tools/xep227toprosody.lua";
-
-	"util/sasl/digest-md5.lua";
+		"tools/ejabberd2prosody.lua";
+		"tools/ejabberdsql2prosody.lua";
+		"tools/erlparse.lua";
+		"tools/jabberd14sql2prosody.lua";
+		"tools/migration/migrator.cfg.lua";
+		"tools/migration/migrator/jabberd14.lua";
+		"tools/migration/migrator/mtools.lua";
+		"tools/migration/migrator/prosody_files.lua";
+		"tools/migration/migrator/prosody_sql.lua";
+		"tools/migration/prosody-migrator.lua";
+		"tools/openfire2prosody.lua";
+		"tools/xep227toprosody.lua";
 	}
 	for _, file in ipairs(exclude_files) do
 		files[file] = { only = {} }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.semgrep.yml	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,24 @@
+rules:
+- id: log-variable-fmtstring
+  patterns:
+    - pattern: log("...", $A)
+    - pattern-not: log("...", "...")
+  message: Variable passed as format string to logging
+  languages: [lua]
+  severity: ERROR
+- id: module-log-variable-fmtstring
+  patterns:
+    - pattern: module:log("...", $A)
+    - pattern-not: module:log("...", "...")
+  message: Variable passed as format string to logging
+  languages: [lua]
+  severity: ERROR
+- id: module-getopt-string-default
+  patterns:
+    - pattern: module:get_option_string("...", $A)
+    - pattern-not: module:get_option_string("...", "...")
+    - pattern-not: module:get_option_string("...", host)
+    - pattern-not: module:get_option_string("...", module.host)
+  message: Non-string default from :get_option_string
+  severity: ERROR
+  languages: [lua]
--- a/CHANGES	Mon Dec 12 07:03:31 2022 +0100
+++ b/CHANGES	Mon Dec 12 07:07:13 2022 +0100
@@ -1,3 +1,91 @@
+0.12.0
+======
+
+**2022-03-14**
+
+## New
+
+### Modules
+
+-   mod_mimicking: Prevent address spoofing
+-   mod_s2s_bidi: Bi-directional server-to-server (XEP-0288)
+-   mod_external_services: generic XEP-0215 support
+-   mod_turn_external: easy setup XEP-0215 for STUN+TURN
+-   mod_http_file_share: File sharing via HTTP (XEP-0363)
+-   mod_http_openmetrics for exposing metrics to stats collectors
+-   mod_smacks: Stream management and resumption (XEP-0198)
+-   mod_auth_ldap: LDAP authentication
+-   mod_cron: One module to rule all the periodic tasks
+-   mod_admin_shell: New home of the Console admin interface
+-   mod_admin_socket: Enable secure connections to the Console
+-   mod_tombstones: Prevent registration of deleted accounts
+-   mod_invites: Create and manage invites
+-   mod_invites_register: Create accounts using invites
+-   mod_invites_adhoc: Create invites via AdHoc command
+-   mod_bookmarks: Synchronise open rooms between clients
+
+### Security and authentication
+
+-   SNI support (including automatic certificate selection)
+-   ALPN support in mod_net_multiplex
+-   DANE support in low-level network layer
+-   Direct TLS support (c2s and s2s)
+-   SCRAM-SHA-256
+-   Direct TLS (including https) certificates updated on reload
+-   Pluggable authorization providers (mod_authz_)
+-   Easy use of Mozilla TLS recommendations presets
+-   Unencrypted HTTP port (5280) restricted to loopback by default
+-   require_encryption options default to 'true' if unspecified
+-   Authentication module defaults to 'internal_hashed' if unspecified
+
+### HTTP
+
+-   CORS handling now provided by mod_http
+-   Built-in HTTP server now handles HEAD requests
+-   Uploads can be handled incrementally
+
+### API
+
+-   Module statuses (API change)
+-   util.error for encapsulating errors
+-   Promise based API for sending queries
+-   API for adding periodic tasks
+-   More APIs supporting ES6 Promises
+-   Async can be used during shutdown
+
+### Other
+
+-   Plugin installer
+-   MUC presence broadcast controls
+-   MUC: support for XEP-0421 occupant identifiers
+-   `prosodyctl check connectivity` via observe.jabber.network
+-   STUN/TURN server tests in `prosodyctl check`
+-   libunbound for DNS queries
+-   The POSIX poll() API used by server_epoll on \*nix other than Linux
+
+## Changes
+
+-   Improved rules for mobile optimizations
+-   Improved rules for what messages should be archived
+-   mod_limits: Exempted JIDs
+-   mod_server_contact_info now loaded on components if enabled
+-   Statistics now based on OpenMetrics
+-   Statistics scheduling can be done by plugin
+-   Offline messages aren't sent to MAM clients
+-   Archive quotas (means?)
+-   Rewritten migrator with archive support
+-   Improved automatic certificate locating and selecting
+-   Logging to syslog no longer missing startup messages
+-   Graceful shutdown sequence that closes ports first and waits for
+    connections to close
+
+## Removed
+
+-   `daemonize` option deprecated
+-   SASL DIGEST-MD5 removed
+-   mod_auth_cyrus (older LDAP support)
+-   Network backend server_select deprecated (not actually removed yet)
+
 0.11.0
 ======
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/CONTRIBUTING	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,9 @@
+Thanks for your interest in contributing to the project!
+
+There are many ways to contribute, such as helping improve the
+documentation, reporting bugs, spreading the word or testing the latest
+development version.
+
+You can find more information on how to contribute at <https://prosody.im/doc/contributing>
+
+See also the HACKERS and README files.
--- a/COPYING	Mon Dec 12 07:03:31 2022 +0100
+++ b/COPYING	Mon Dec 12 07:07:13 2022 +0100
@@ -1,5 +1,12 @@
-Copyright (c) 2008-2011 Matthew Wild
-Copyright (c) 2008-2011 Waqas Hussain
+All source code in this project is released under the below MIT license. Some
+components are not authored by the Prosody maintainers, but such code is
+itself either released under a MIT license or declared public domain.
+
+---
+
+Copyright (C) 2008-2022 Matthew Wild
+Copyright (C) 2008-2020 Waqas Hussain
+Copyright (C) 2010-2022 Kim Alvefur
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
@@ -18,3 +25,20 @@
 CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
 TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
 SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+---
+
+util-src/encodings.c:
+  Parts included from Lua 5.3. Copyright (C) 1994-2015 Lua.org, PUC-Rio.
+
+util-src/signal.c:
+  Copyright (C) 2007  Patrick J. Donnelly (batrick@batbytes.com)
+  See full copyright notice in the source file.
+
+util-src/struct.c:
+  Copyright (C) 2010-2018 Lua.org, PUC-Rio. All rights reserved.
+  See full copyright notice in the source file.
+
+net/dns.lua:
+  public domain 20080404 lua@ztact.com
+
--- a/GNUmakefile	Mon Dec 12 07:03:31 2022 +0100
+++ b/GNUmakefile	Mon Dec 12 07:07:13 2022 +0100
@@ -21,6 +21,7 @@
 
 LUACHECK=luacheck
 BUSTED=busted
+SCANSION=scansion
 
 .PHONY: all test coverage clean install
 
@@ -30,36 +31,64 @@
 	-$(MAKE) -C certs localhost.crt example.com.crt
 endif
 
-install: prosody.install prosodyctl.install prosody.cfg.lua.install util/encodings.so util/encodings.so util/pposix.so util/signal.so
-	$(MKDIR) $(BIN) $(CONFIG) $(MODULES) $(SOURCE)
-	$(MKDIR_PRIVATE) $(DATA)
-	$(MKDIR) $(MAN)/man1
+install-etc: prosody.cfg.lua.install
+	$(MKDIR) $(CONFIG)
 	$(MKDIR) $(CONFIG)/certs
-	$(MKDIR) $(SOURCE)/core $(SOURCE)/net $(SOURCE)/util
+	$(INSTALL_DATA) certs/* $(CONFIG)/certs
+	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
+
+install-bin: prosody.install prosodyctl.install
+	$(MKDIR) $(BIN)
 	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
 	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
+
+install-core:
+	$(MKDIR) $(SOURCE)
+	$(MKDIR) $(SOURCE)/core
 	$(INSTALL_DATA) core/*.lua $(SOURCE)/core
+
+install-net:
+	$(MKDIR) $(SOURCE)
+	$(MKDIR) $(SOURCE)/net
 	$(INSTALL_DATA) net/*.lua $(SOURCE)/net
 	$(MKDIR) $(SOURCE)/net/http $(SOURCE)/net/resolvers $(SOURCE)/net/websocket
 	$(INSTALL_DATA) net/http/*.lua $(SOURCE)/net/http
 	$(INSTALL_DATA) net/resolvers/*.lua $(SOURCE)/net/resolvers
 	$(INSTALL_DATA) net/websocket/*.lua $(SOURCE)/net/websocket
+
+install-util: util/encodings.so util/encodings.so util/pposix.so util/signal.so util/struct.so
+	$(MKDIR) $(SOURCE)
+	$(MKDIR) $(SOURCE)/util
 	$(INSTALL_DATA) util/*.lua $(SOURCE)/util
+	$(MAKE) install -C util-src
 	$(INSTALL_DATA) util/*.so $(SOURCE)/util
 	$(MKDIR) $(SOURCE)/util/sasl
 	$(INSTALL_DATA) util/sasl/*.lua $(SOURCE)/util/sasl
-	$(MKDIR) $(MODULES)/mod_s2s $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
+	$(MKDIR) $(SOURCE)/util/human
+	$(INSTALL_DATA) util/human/*.lua $(SOURCE)/util/human
+	$(MKDIR) $(SOURCE)/util/prosodyctl
+	$(INSTALL_DATA) util/prosodyctl/*.lua $(SOURCE)/util/prosodyctl
+
+install-plugins:
+	$(MKDIR) $(MODULES)
+	$(MKDIR) $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
 	$(INSTALL_DATA) plugins/*.lua $(MODULES)
-	$(INSTALL_DATA) plugins/mod_s2s/*.lua $(MODULES)/mod_s2s
 	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
 	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
 	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
 	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
-	$(INSTALL_DATA) certs/* $(CONFIG)/certs
+
+install-man:
+	$(MKDIR) $(MAN)/man1
 	$(INSTALL_DATA) man/prosodyctl.man $(MAN)/man1/prosodyctl.1
-	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
+
+install-meta:
 	-test -f prosody.version && $(INSTALL_DATA) prosody.version $(SOURCE)/prosody.version
-	$(MAKE) install -C util-src
+
+install-data:
+	$(MKDIR_PRIVATE) $(DATA)
+
+install: install-util install-net install-core install-plugins install-bin install-etc install-man install-meta install-data
 
 clean:
 	rm -f prosody.install
@@ -71,6 +100,23 @@
 test:
 	$(BUSTED) --lua=$(RUNWITH)
 
+test-%:
+	$(BUSTED) --lua=$(RUNWITH) -r $*
+
+integration-test: all
+	$(MKDIR) data
+	$(RUNWITH) prosodyctl --config ./spec/scansion/prosody.cfg.lua start
+	$(SCANSION) -d ./spec/scansion; R=$$? \
+	$(RUNWITH) prosodyctl --config ./spec/scansion/prosody.cfg.lua stop \
+	exit $$R
+
+integration-test-%: all
+	$(MKDIR) data
+	$(RUNWITH) prosodyctl --config ./spec/scansion/prosody.cfg.lua start
+	$(SCANSION) ./spec/scansion/$*.scs; R=$$? \
+	$(RUNWITH) prosodyctl --config ./spec/scansion/prosody.cfg.lua stop \
+	exit $$R
+
 coverage:
 	-rm -- luacov.*
 	$(BUSTED) --lua=$(RUNWITH) -c
@@ -84,6 +130,13 @@
 	@echo $$(sed -n '/^\tlocal exclude_files/,/^}/p;' .luacheckrc | sed '1d;$d' | wc -l) files ignored
 	shellcheck configure
 
+vpath %.tl teal-src/
+%.lua: %.tl
+	tl -I teal-src/ --gen-compat off --gen-target 5.1 gen $^ -o $@
+	-lua-format -i $@
+
+teal: util/jsonschema.lua util/datamapper.lua util/jsonpointer.lua
+
 util/%.so:
 	$(MAKE) install -C util-src
 
--- a/HACKERS	Mon Dec 12 07:03:31 2022 +0100
+++ b/HACKERS	Mon Dec 12 07:07:13 2022 +0100
@@ -5,7 +5,7 @@
 information on these at https://prosody.im/discuss
 
 Patches are welcome, though before sending we would appreciate if you read 
-docs/coding_style.txt for guidelines on how to format your code, and other tips.
+docs/coding_style.md for guidelines on how to format your code, and other tips.
 
 Documentation for developers can be found at https://prosody.im/doc/developers
 
--- a/INSTALL	Mon Dec 12 07:03:31 2022 +0100
+++ b/INSTALL	Mon Dec 12 07:07:13 2022 +0100
@@ -1,73 +1,79 @@
 (This file was created from
 https://prosody.im/doc/installing_from_source on 2013-03-31)
 
-====== Installing from source ======
-==== Dependencies ====
-There are a couple of libraries which Prosody needs installed before 
-you can build it. These are:
+# Installing from source
+
+## Dependencies
+
+There are a couple of development packages which Prosody needs installed
+before you can build it. These are:
 
-  * lua5.1: The Lua 5.1 interpreter
-  * liblua5.1: Lua 5.1 library
-  * libssl 0.9.8: OpenSSL
-  * libidn11: GNU libidn library, version 1.1
+-   The [Lua](http://lua.org/) library, version 5.4 recommended
+-   [OpenSSL](http://openssl.org/)
+-   String processing library, one of
+    -   [ICU](https://icu.unicode.org/) (recommended)
+    -   [GNU libidn](http://www.gnu.org/software/libidn/)
 
-These can be installed on Debian/Ubuntu with the packages: lua5.1 
-liblua5.1-dev libidn11-dev libssl-dev
+These can be installed on Debian/Ubuntu by running
+`apt build-dep prosody` or by installing the packages
+`liblua5.4-dev`, `libicu-dev` and `libssl-dev`.
 
-On Mandriva try: urpmi lua liblua-devel libidn-devel libopenssl-devel
+On Mandriva try:
 
-On other systems... good luck, but please let me know of the best way 
-of getting the dependencies for your system and I can add it here.
+	urpmi lua liblua-devel libidn-devel libopenssl-devel
 
-==== configure ====
-The first step of building is to run the configure script. This 
-creates a file called 'config.unix' which is used by the next step to 
-control aspects of the build process.
+On Mac OS X, if you have MacPorts installed, you can try:
+
+	sudo port install lua lua-luasocket lua-luasec lua-luaexpat
 
-All options to configure can be seen by running ./configure --help. 
-Sometimes you won't need to pass any parameters to configure, but on 
-most systems you shall.
+On other systems... good luck, but please let us know of the best way of
+getting the dependencies for your system and we can add it here.
+
+## configure
 
-To make this a little easier, there are a few presets which configure 
-accepts. You can load a preset using:
+The first step of building is to run the configure script. This creates
+a file called 'config.unix' which is used by the next step to control
+aspects of the build process.
 
-   ./configure --ostype=PRESET
+	./configure
 
-Where PRESET can currently be one of: 'debian', 'macosx' or (in 0.8 
-and later) 'freebsd'
+All options to configure can be seen by running
 
-==== make ====
+	./configure --help
+
+## make
+
 Once you have run configure successfully, then you can simply run:
 
    make
 
 Simple? :-)
 
-If you do happen to have problems at this stage, it is most likely 
-due to the build process not finding the dependencies. Ensure you 
-have them installed, and in the standard library paths for your 
-system.
+If you do happen to have problems at this stage, it is most likely due
+to the build process not finding the dependencies. Ensure you have them
+installed, and in the standard library paths for your system.
 
 For more help, just ask ;-)
 
 ==== install ====
+
 At this stage you should be able to run Prosody simply with:
 
    ./prosody
 
-There is no problem with this, it is actually the easiest way to do 
-development, as it doesn't spread parts around your system, and you 
-can keep multiple versions around in their own directories without 
+There is no problem with this, it is actually the easiest way to do
+development, as it doesn't spread parts around your system, and you
+can keep multiple versions around in their own directories without
 conflict.
 
 Should you wish to install it system-wide however, simply run:
 
    sudo make install
 
-...it will install into /usr/local/ by default. To change this you 
-can pass to the initial ./configure using the 'prefix' option, or 
-edit config.unix directly. If the new path doesn't require root 
-permission to write to, you also won't need (or want) to use 'sudo' 
-in front of the 'make install'.
+...it will install into /usr/local/ by default. To change this you can
+pass to the initial ./configure using the 'prefix' option, or edit
+config.unix directly. If the new path doesn't require root permission to
+write to, you also won't need (or want) to use 'sudo' in front of the
+'make install'.
 
 Have fun, and see you on Jabber!
--- a/README	Mon Dec 12 07:03:31 2022 +0100
+++ b/README	Mon Dec 12 07:07:13 2022 +0100
@@ -2,36 +2,34 @@
 
 ## Description
 
-Prosody is a server for Jabber/XMPP written in Lua. It aims to be easy 
-to use and light on resources. For developers, it aims to give a 
-flexible system on which to rapidly develop added functionality or 
-rapidly prototype new protocols.
+Prosody is a server for Jabber/XMPP written in Lua. It aims to be easy to use
+and light on resources. For developers, it aims to give a flexible system on
+which to rapidly develop added functionality or rapidly prototype new
+protocols.
 
 ## Useful links
 
 Homepage:        https://prosody.im/
 Download:        https://prosody.im/download
 Documentation:   https://prosody.im/doc/
+Issue tracker:   https://issues.prosody.im/
 
 Jabber/XMPP Chat:
                Address:
                  prosody@conference.prosody.im
                Web interface:
-                 https://prosody.im/webchat
-               
+                 https://chat.prosody.im/
+
 Mailing lists:
                User support and discussion:
                  https://groups.google.com/group/prosody-users
-               
+
                Development discussion:
                  https://groups.google.com/group/prosody-dev
-               
-               Issue tracker changes:
-                 https://groups.google.com/group/prosody-issues
 
 ## Installation
 
-See the accompanying INSTALL file for help on building Prosody from source. Alternatively 
-see our guide at https://prosody.im/doc/install
+See the accompanying INSTALL file for help on building Prosody from source.
+Alternatively see our guide at https://prosody.im/doc/install
 
 
--- a/TODO	Mon Dec 12 07:03:31 2022 +0100
+++ b/TODO	Mon Dec 12 07:07:13 2022 +0100
@@ -1,5 +1,4 @@
 == 1.0 ==
 - Roster providers
-- Statistics
 - Clustering
 - World domination
--- a/configure	Mon Dec 12 07:03:31 2022 +0100
+++ b/configure	Mon Dec 12 07:07:13 2022 +0100
@@ -23,10 +23,11 @@
 PRNG=
 PRNGLIBS=
 
-CFLAGS="-fPIC -Wall -pedantic -std=c99"
+CFLAGS="-fPIC -std=c99"
+CFLAGS="$CFLAGS -Wall -pedantic -Wextra -Wshadow -Wformat=2"
 LDFLAGS="-shared"
 
-IDN_LIBRARY="idn"
+IDN_LIBRARY="icu"
 # Help
 
 show_help() {
@@ -62,8 +63,8 @@
 --with-idn=LIB              The name of the IDN library to link with.
                             Default is $IDN_LIB
 --idn-library=(idn|icu)     Select library to use for IDNA functionality.
-                            idn: use GNU libidn (default)
-                            icu: use ICU from IBM
+                            idn: use GNU libidn
+                            icu: use ICU from IBM (default)
 --with-ssl=LIB              The name of the SSL to link with.
                             Default is $OPENSSL_LIB
 --with-random=METHOD        CSPRNG backend to use. One of
@@ -107,7 +108,8 @@
    exit 1
 }
 
-# shellcheck disable=SC2039
+# COMPAT SC2039 has been phased out, remove in the future
+# shellcheck disable=SC2039,SC3037
 case $(echo -n x) in
 -n*) echo_n_flag='';;
 *)   echo_n_flag='-n';;
@@ -152,74 +154,8 @@
       SYSCONFDIR_SET=yes
       ;;
    --ostype)
-	# TODO make this a switch?
       OSPRESET="$value"
-      if [ "$OSPRESET" = "debian" ]; then
-         if [ "$LUA_SUFFIX_SET" != "yes" ]; then
-            LUA_SUFFIX="5.1";
-            LUA_SUFFIX_SET=yes
-         fi
-         if [ "$RUNWITH_SET" != "yes" ]; then
-            RUNWITH="lua$LUA_SUFFIX";
-            RUNWITH_SET=yes
-         fi
-         LUA_INCDIR="/usr/include/lua$LUA_SUFFIX"
-         LUA_INCDIR_SET=yes
-         CFLAGS="$CFLAGS -ggdb"
-      fi
-      if [ "$OSPRESET" = "macosx" ]; then
-         LUA_INCDIR=/usr/local/include;
-         LUA_INCDIR_SET=yes
-         LUA_LIBDIR=/usr/local/lib
-         LUA_LIBDIR_SET=yes
-         CFLAGS="$CFLAGS -mmacosx-version-min=10.3"
-         LDFLAGS="-bundle -undefined dynamic_lookup"
-      fi
-      if [ "$OSPRESET" = "linux" ]; then
-         LUA_INCDIR=/usr/local/include;
-         LUA_INCDIR_SET=yes
-         LUA_LIBDIR=/usr/local/lib
-         LUA_LIBDIR_SET=yes
-         CFLAGS="$CFLAGS -ggdb"
-      fi
-      if [ "$OSPRESET" = "freebsd" ] || [ "$OSPRESET" = "openbsd" ]; then
-         LUA_INCDIR="/usr/local/include/lua51"
-         LUA_INCDIR_SET=yes
-         CFLAGS="-Wall -fPIC -I/usr/local/include"
-         LDFLAGS="-I/usr/local/include -L/usr/local/lib -shared"
-         LUA_SUFFIX="51"
-         LUA_SUFFIX_SET=yes
-         LUA_DIR=/usr/local
-         LUA_DIR_SET=yes
-         CC=cc
-         LD=ld
-      fi
-      if [ "$OSPRESET" = "openbsd" ]; then
-         LUA_INCDIR="/usr/local/include";
-         LUA_INCDIR_SET="yes"
-      fi
-      if [ "$OSPRESET" = "netbsd" ]; then
-         LUA_INCDIR="/usr/pkg/include/lua-5.1"
-         LUA_INCDIR_SET=yes
-         LUA_LIBDIR="/usr/pkg/lib/lua/5.1"
-         LUA_LIBDIR_SET=yes
-         CFLAGS="-Wall -fPIC -I/usr/pkg/include"
-         LDFLAGS="-L/usr/pkg/lib -Wl,-rpath,/usr/pkg/lib -shared"
-      fi
-      if [ "$OSPRESET" = "pkg-config" ]; then
-         if [ "$LUA_SUFFIX_SET" != "yes" ]; then
-            LUA_SUFFIX="5.1";
-            LUA_SUFFIX_SET=yes
-         fi
-         LUA_CF="$(pkg-config --cflags-only-I lua$LUA_SUFFIX)"
-         LUA_CF="${LUA_CF#*-I}"
-         LUA_CF="${LUA_CF%% *}"
-         if [ "$LUA_CF" != "" ]; then
-            LUA_INCDIR="$LUA_CF"
-            LUA_INCDIR_SET=yes
-         fi
-         CFLAGS="$CFLAGS"
-      fi
+      OSPRESET_SET="yes"
       ;;
    --libdir)
       LIBDIR="$value"
@@ -237,7 +173,7 @@
    --lua-version|--with-lua-version)
       [ -n "$value" ] || die "Missing value in flag $key."
       LUA_VERSION="$value"
-      [ "$LUA_VERSION" = "5.1" ] || [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || die "Invalid Lua version in flag $key."
+      [ "$LUA_VERSION" = "5.1" ] || [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || [ "$LUA_VERSION" = "5.4" ] || die "Invalid Lua version in flag $key."
       LUA_VERSION_SET=yes
       ;;
    --with-lua)
@@ -318,6 +254,65 @@
    shift
 done
 
+if [ "$OSPRESET_SET" = "yes" ]; then
+	# TODO make this a switch?
+   if [ "$OSPRESET" = "debian" ]; then
+      CFLAGS="$CFLAGS -ggdb"
+   fi
+   if [ "$OSPRESET" = "macosx" ]; then
+      if [ "$LUA_INCDIR_SET" != "yes" ]; then
+         LUA_INCDIR=/usr/local/include;
+         LUA_INCDIR_SET=yes
+      fi
+      if [ "$LUA_LIBDIR_SET" != "yes" ]; then
+         LUA_LIBDIR=/usr/local/lib
+         LUA_LIBDIR_SET=yes
+      fi
+      CFLAGS="$CFLAGS -mmacosx-version-min=10.3"
+      LDFLAGS="-bundle -undefined dynamic_lookup"
+   fi
+   if [ "$OSPRESET" = "linux" ]; then
+      CFLAGS="$CFLAGS -ggdb"
+   fi
+   if [ "$OSPRESET" = "freebsd" ] || [ "$OSPRESET" = "openbsd" ]; then
+      LUA_INCDIR="/usr/local/include/lua51"
+      LUA_INCDIR_SET=yes
+      CFLAGS="-Wall -fPIC -I/usr/local/include"
+      LDFLAGS="-I/usr/local/include -L/usr/local/lib -shared"
+      LUA_SUFFIX="51"
+      LUA_SUFFIX_SET=yes
+      LUA_DIR=/usr/local
+      LUA_DIR_SET=yes
+      CC=cc
+      LD=ld
+   fi
+   if [ "$OSPRESET" = "openbsd" ]; then
+      LUA_INCDIR="/usr/local/include";
+      LUA_INCDIR_SET="yes"
+   fi
+   if [ "$OSPRESET" = "netbsd" ]; then
+      LUA_INCDIR="/usr/pkg/include/lua-5.1"
+      LUA_INCDIR_SET=yes
+      LUA_LIBDIR="/usr/pkg/lib/lua/5.1"
+      LUA_LIBDIR_SET=yes
+      CFLAGS="-Wall -fPIC -I/usr/pkg/include"
+      LDFLAGS="-L/usr/pkg/lib -Wl,-rpath,/usr/pkg/lib -shared"
+   fi
+   if [ "$OSPRESET" = "pkg-config" ]; then
+      if [ "$LUA_SUFFIX_SET" != "yes" ]; then
+         LUA_SUFFIX="5.1";
+         LUA_SUFFIX_SET=yes
+      fi
+      LUA_CF="$(pkg-config --cflags-only-I lua$LUA_SUFFIX)"
+      LUA_CF="${LUA_CF#*-I}"
+      LUA_CF="${LUA_CF%% *}"
+      if [ "$LUA_CF" != "" ]; then
+         LUA_INCDIR="$LUA_CF"
+         LUA_INCDIR_SET=yes
+      fi
+   fi
+fi
+
 if [ "$PREFIX_SET" = "yes" ] && [ ! "$SYSCONFDIR_SET" = "yes" ]
 then
    if [ "$PREFIX" = "/usr" ]
@@ -340,7 +335,7 @@
 fi
 
 detect_lua_version() {
-   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[123])$"))' 2> /dev/null)
+   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[1234])$"))' 2> /dev/null)
    if [ "$detected_lua" != "nil" ]
    then
       if [ "$LUA_VERSION_SET" != "yes" ]
@@ -403,8 +398,14 @@
    elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.3" ]
    then
       suffixes="5.3 53 -5.3 -53"
+   elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.4" ]
+   then
+      suffixes="5.4 54 -5.4 -54"
    else
-      suffixes="5.1 51 -5.1 -51 5.2 52 -5.2 -52 5.3 53 -5.3 -53"
+      suffixes="5.1 51 -5.1 -51"
+      suffixes="$suffixes 5.2 52 -5.2 -52"
+      suffixes="$suffixes 5.3 53 -5.3 -53"
+      suffixes="$suffixes 5.4 54 -5.4 -54"
    fi
    for suffix in "" $suffixes
    do
@@ -464,30 +465,46 @@
    LUA_LIBDIR="$LUA_DIR/lib"
 fi
 
-echo_n "Checking Lua includes... "
 lua_h="$LUA_INCDIR/lua.h"
+echo_n "Looking for lua.h at $lua_h..."
 if [ -f "$lua_h" ]
 then
-   echo "lua.h found in $lua_h"
+   echo found
 else
-   v_dir="$LUA_INCDIR/lua/$LUA_VERSION"
-   lua_h="$v_dir/lua.h"
-   if [ -f "$lua_h" ]
-   then
-      echo "lua.h found in $lua_h"
+  echo "not found"
+  for postfix in "$LUA_VERSION" "$LUA_SUFFIX"; do
+    if ! [ "$postfix" = "" ]; then
+      v_dir="$LUA_INCDIR/lua/$postfix";
+    else
+      v_dir="$LUA_INCDIR/lua";
+    fi
+    lua_h="$v_dir/lua.h"
+    echo_n "Looking for lua.h at $lua_h..."
+    if [ -f "$lua_h" ]
+    then
       LUA_INCDIR="$v_dir"
-   else
-      d_dir="$LUA_INCDIR/lua$LUA_VERSION"
+      echo found
+      break;
+    else
+      echo "not found"
+      d_dir="$LUA_INCDIR/lua$postfix"
       lua_h="$d_dir/lua.h"
+      echo_n "Looking for lua.h at $lua_h..."
       if [ -f "$lua_h" ]
       then
-         echo "lua.h found in $lua_h (Debian/Ubuntu)"
-         LUA_INCDIR="$d_dir"
+        echo found
+        LUA_INCDIR="$d_dir"
+        break;
       else
-         echo "lua.h not found (looked in $LUA_INCDIR, $v_dir, $d_dir)"
-         die "You may want to use the flag --with-lua or --with-lua-include. See --help."
+        echo "not found"
       fi
-   fi
+    fi
+  done
+  if [ ! -f "$lua_h" ]; then
+    echo "lua.h not found."
+    echo
+    die "You may want to use the flag --with-lua or --with-lua-include. See --help."
+  fi
 fi
 
 if [ "$lua_interp_found" = "yes" ]
@@ -507,7 +524,7 @@
 if [ "$IDN_LIBRARY" = "icu" ]
 then
    IDNA_LIBS="$ICU_FLAGS"
-   CFLAGS="$CFLAGS -DUSE_STRINGPREP_ICU"
+   IDNA_FLAGS="-DUSE_STRINGPREP_ICU"
 fi
 if [ "$IDN_LIBRARY" = "idn" ]
 then
@@ -552,6 +569,7 @@
 LUA_LIBDIR=$LUA_LIBDIR
 LUA_BINDIR=$LUA_BINDIR
 IDN_LIB=$IDN_LIB
+IDNA_FLAGS=$IDNA_FLAGS
 IDNA_LIBS=$IDNA_LIBS
 OPENSSL_LIBS=$OPENSSL_LIBS
 CFLAGS=$CFLAGS
--- a/core/certmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/certmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -6,34 +6,30 @@
 -- COPYING file in the source package for more information.
 --
 
-local softreq = require"util.dependencies".softreq;
-local ssl = softreq"ssl";
-if not ssl then
-	return {
-		create_context = function ()
-			return nil, "LuaSec (required for encryption) was not found";
-		end;
-		reload_ssl_config = function () end;
-	}
-end
-
+local ssl = require "ssl";
 local configmanager = require "core.configmanager";
 local log = require "util.logger".init("certmanager");
-local ssl_context = ssl.context or softreq"ssl.context";
-local ssl_x509 = ssl.x509 or softreq"ssl.x509";
+local ssl_context = ssl.context or require "ssl.context";
 local ssl_newcontext = ssl.newcontext;
 local new_config = require"util.sslconfig".new;
 local stat = require "lfs".attributes;
 
+local x509 = require "util.x509";
+local lfs = require "lfs";
+
 local tonumber, tostring = tonumber, tostring;
 local pairs = pairs;
 local t_remove = table.remove;
 local type = type;
 local io_open = io.open;
 local select = select;
+local now = os.time;
+local next = next;
+local pcall = pcall;
 
 local prosody = prosody;
-local resolve_path = require"util.paths".resolve_relative_path;
+local pathutil = require"util.paths";
+local resolve_path = pathutil.resolve_relative_path;
 local config_path = prosody.paths.config or ".";
 
 local function test_option(option)
@@ -42,7 +38,7 @@
 
 local luasec_major, luasec_minor = ssl._VERSION:match("^(%d+)%.(%d+)");
 local luasec_version = tonumber(luasec_major) * 100 + tonumber(luasec_minor);
-local luasec_has = ssl.config or softreq"ssl.config" or {
+local luasec_has = ssl.config or {
 	algorithms = {
 		ec = luasec_version >= 5;
 	};
@@ -81,7 +77,7 @@
 			if crt_path == key_path then
 				if key_path:sub(-4) == ".crt" then
 					key_path = key_path:sub(1, -4) .. "key";
-				elseif key_path:sub(-13) == "fullchain.pem" then
+				elseif key_path:sub(-14) == "/fullchain.pem" then
 					key_path = key_path:sub(1, -14) .. "privkey.pem";
 				end
 			end
@@ -95,12 +91,108 @@
 	log("debug", "No certificate/key found for %s", name);
 end
 
+local function find_matching_key(cert_path)
+	return (cert_path:gsub("%.crt$", ".key"):gsub("fullchain", "privkey"));
+end
+
+local function index_certs(dir, files_by_name, depth_limit)
+	files_by_name = files_by_name or {};
+	depth_limit = depth_limit or 3;
+	if depth_limit <= 0 then return files_by_name; end
+
+	local ok, iter, v, i = pcall(lfs.dir, dir);
+	if not ok then
+		log("error", "Error indexing certificate directory %s: %s", dir, iter);
+		-- Return an empty index, otherwise this just triggers a nil indexing
+		-- error, plus this function would get called again.
+		-- Reloading the config after correcting the problem calls this again so
+		-- that's what should be done.
+		return {}, iter;
+	end
+	for file in iter, v, i do
+		local full = pathutil.join(dir, file);
+		if lfs.attributes(full, "mode") == "directory" then
+			if file:sub(1,1) ~= "." then
+				index_certs(full, files_by_name, depth_limit-1);
+			end
+		elseif file:find("%.crt$") or file:find("fullchain") then -- This should catch most fullchain files
+			local f = io_open(full);
+			if f then
+				-- TODO look for chained certificates
+				local firstline = f:read();
+				if firstline == "-----BEGIN CERTIFICATE-----" and lfs.attributes(find_matching_key(full), "mode") == "file" then
+					f:seek("set")
+					local cert = ssl.loadcertificate(f:read("*a"))
+					-- TODO if more than one cert is found for a name, the most recently
+					-- issued one should be used.
+					-- for now, just filter out expired certs
+					-- TODO also check if there's a corresponding key
+					if cert:validat(now()) then
+						local names = x509.get_identities(cert);
+						log("debug", "Found certificate %s with identities %q", full, names);
+						for name, services in pairs(names) do
+							-- TODO check services
+							if files_by_name[name] then
+								files_by_name[name][full] = services;
+							else
+								files_by_name[name] = { [full] = services; };
+							end
+						end
+					end
+				end
+				f:close();
+			end
+		end
+	end
+	log("debug", "Certificate index: %q", files_by_name);
+	-- | hostname | filename | service |
+	return files_by_name;
+end
+
+local cert_index;
+
+local function find_cert_in_index(index, host)
+	if not host then return nil; end
+	if not index then return nil; end
+	local wildcard_host = host:gsub("^[^.]+%.", "*.");
+	local certs = index[host] or index[wildcard_host];
+	if certs then
+		local cert_filename, services = next(certs);
+		if services["*"] then
+			log("debug", "Using cert %q from index for host %q", cert_filename, host);
+			return {
+				certificate = cert_filename,
+				key = find_matching_key(cert_filename),
+			}
+		end
+	end
+	return nil
+end
+
 local function find_host_cert(host)
 	if not host then return nil; end
-	return find_cert(configmanager.get(host, "certificate"), host) or find_host_cert(host:match("%.(.+)$"));
+	if not cert_index then
+		cert_index = index_certs(resolve_path(config_path, global_certificates));
+	end
+
+	return find_cert_in_index(cert_index, host) or find_cert(configmanager.get(host, "certificate"), host) or find_host_cert(host:match("%.(.+)$"));
 end
 
 local function find_service_cert(service, port)
+	if not cert_index then
+		cert_index = index_certs(resolve_path(config_path, global_certificates));
+	end
+	for _, certs in pairs(cert_index) do
+		for cert_filename, services in pairs(certs) do
+			if services[service] or services["*"] then
+				log("debug", "Using cert %q from index for service %s port %d", cert_filename, service, port);
+				return {
+					certificate = cert_filename,
+					key = find_matching_key(cert_filename),
+				}
+			end
+		end
+	end
 	local cert_config = configmanager.get("*", service.."_certificate");
 	if type(cert_config) == "table" then
 		cert_config = cert_config[port] or cert_config.default;
@@ -113,7 +205,7 @@
 	capath = "/etc/ssl/certs";
 	depth = 9;
 	protocol = "tlsv1+";
-	verify = (ssl_x509 and { "peer", "client_once", }) or "none";
+	verify = "none";
 	options = {
 		cipher_server_preference = luasec_has.options.cipher_server_preference;
 		no_ticket = luasec_has.options.no_ticket;
@@ -122,7 +214,10 @@
 		single_ecdh_use = luasec_has.options.single_ecdh_use;
 		no_renegotiation = luasec_has.options.no_renegotiation;
 	};
-	verifyext = { "lsec_continue", "lsec_ignore_purpose" };
+	verifyext = {
+		"lsec_continue", -- Continue past certificate verification errors
+		"lsec_ignore_purpose", -- Validate client certificates as if they were server certificates
+	};
 	curve = luasec_has.algorithms.ec and not luasec_has.capabilities.curves_list and "secp384r1";
 	curveslist = {
 		"X25519",
@@ -140,8 +235,74 @@
 		"!3DES",       -- 3DES - slow and of questionable security
 		"!aNULL",      -- Ciphers that does not authenticate the connection
 	};
+	dane = luasec_has.capabilities.dane and configmanager.get("*", "use_dane") and { "no_ee_namechecks" };
 }
 
+local mozilla_ssl_configs = {
+	-- https://wiki.mozilla.org/Security/Server_Side_TLS
+	-- Version 5.6 as of 2021-12-26
+	modern = {
+		protocol = "tlsv1_3";
+		options = { cipher_server_preference = false };
+		ciphers = "DEFAULT"; -- TLS 1.3 uses 'ciphersuites' rather than these
+		curveslist = { "X25519"; "prime256v1"; "secp384r1" };
+		ciphersuites = { "TLS_AES_128_GCM_SHA256"; "TLS_AES_256_GCM_SHA384"; "TLS_CHACHA20_POLY1305_SHA256" };
+	};
+	intermediate = {
+		protocol = "tlsv1_2+";
+		dhparam = nil; -- ffdhe2048.txt
+		options = { cipher_server_preference = false };
+		ciphers = {
+			"ECDHE-ECDSA-AES128-GCM-SHA256";
+			"ECDHE-RSA-AES128-GCM-SHA256";
+			"ECDHE-ECDSA-AES256-GCM-SHA384";
+			"ECDHE-RSA-AES256-GCM-SHA384";
+			"ECDHE-ECDSA-CHACHA20-POLY1305";
+			"ECDHE-RSA-CHACHA20-POLY1305";
+			"DHE-RSA-AES128-GCM-SHA256";
+			"DHE-RSA-AES256-GCM-SHA384";
+		};
+		curveslist = { "X25519"; "prime256v1"; "secp384r1" };
+		ciphersuites = { "TLS_AES_128_GCM_SHA256"; "TLS_AES_256_GCM_SHA384"; "TLS_CHACHA20_POLY1305_SHA256" };
+	};
+	old = {
+		protocol = "tlsv1+";
+		dhparam = nil; -- openssl dhparam 1024
+		options = { cipher_server_preference = true };
+		ciphers = {
+			"ECDHE-ECDSA-AES128-GCM-SHA256";
+			"ECDHE-RSA-AES128-GCM-SHA256";
+			"ECDHE-ECDSA-AES256-GCM-SHA384";
+			"ECDHE-RSA-AES256-GCM-SHA384";
+			"ECDHE-ECDSA-CHACHA20-POLY1305";
+			"ECDHE-RSA-CHACHA20-POLY1305";
+			"DHE-RSA-AES128-GCM-SHA256";
+			"DHE-RSA-AES256-GCM-SHA384";
+			"DHE-RSA-CHACHA20-POLY1305";
+			"ECDHE-ECDSA-AES128-SHA256";
+			"ECDHE-RSA-AES128-SHA256";
+			"ECDHE-ECDSA-AES128-SHA";
+			"ECDHE-RSA-AES128-SHA";
+			"ECDHE-ECDSA-AES256-SHA384";
+			"ECDHE-RSA-AES256-SHA384";
+			"ECDHE-ECDSA-AES256-SHA";
+			"ECDHE-RSA-AES256-SHA";
+			"DHE-RSA-AES128-SHA256";
+			"DHE-RSA-AES256-SHA256";
+			"AES128-GCM-SHA256";
+			"AES256-GCM-SHA384";
+			"AES128-SHA256";
+			"AES256-SHA256";
+			"AES128-SHA";
+			"AES256-SHA";
+			"DES-CBC3-SHA";
+		};
+		curveslist = { "X25519"; "prime256v1"; "secp384r1" };
+		ciphersuites = { "TLS_AES_128_GCM_SHA256"; "TLS_AES_256_GCM_SHA384"; "TLS_CHACHA20_POLY1305_SHA256" };
+	};
+};
+
+
 if luasec_has.curves then
 	for i = #core_defaults.curveslist, 1, -1 do
 		if not luasec_has.curves[ core_defaults.curveslist[i] ] then
@@ -156,20 +317,16 @@
 	key = true, certificate = true, cafile = true, capath = true, dhparam = true
 }
 
-if luasec_version < 5 and ssl_x509 then
-	-- COMPAT mw/luasec-hg
-	for i=1,#core_defaults.verifyext do -- Remove lsec_ prefix
-		core_defaults.verify[#core_defaults.verify+1] = core_defaults.verifyext[i]:sub(6);
-	end
-end
-
 local function create_context(host, mode, ...)
 	local cfg = new_config();
 	cfg:apply(core_defaults);
 	local service_name, port = host:match("^(%S+) port (%d+)$");
-	if service_name then
+	-- port 0 is used with client-only things that normally don't need certificates, e.g. https
+	if service_name and port ~= "0" then
+		log("debug", "Automatically locating certs for service %s on port %s", service_name, port);
 		cfg:apply(find_service_cert(service_name, tonumber(port)));
 	else
+		log("debug", "Automatically locating certs for host %s", host);
 		cfg:apply(find_host_cert(host));
 	end
 	cfg:apply({
@@ -177,6 +334,10 @@
 		-- We can't read the password interactively when daemonized
 		password = function() log("error", "Encrypted certificate for %s requires 'ssl' 'password' to be set in config", host); end;
 	});
+	local profile = configmanager.get("*", "tls_profile") or "intermediate";
+	if profile ~= "legacy" then
+		cfg:apply(mozilla_ssl_configs[profile]);
+	end
 	cfg:apply(global_ssl_config);
 
 	for i = select('#', ...), 1, -1 do
@@ -185,8 +346,10 @@
 	local user_ssl_config = cfg:final();
 
 	if mode == "server" then
-		if not user_ssl_config.certificate then return nil, "No certificate present in SSL/TLS configuration for "..host; end
-		if not user_ssl_config.key then return nil, "No key present in SSL/TLS configuration for "..host; end
+		if not user_ssl_config.certificate then
+			log("info", "No certificate present in SSL/TLS configuration for %s. SNI will be required.", host);
+		end
+		if user_ssl_config.certificate and not user_ssl_config.key then return nil, "No key present in SSL/TLS configuration for "..host; end
 	end
 
 	for option in pairs(path_options) do
@@ -258,6 +421,8 @@
 	if luasec_has.options.no_compression then
 		core_defaults.options.no_compression = configmanager.get("*", "ssl_compression") ~= true;
 	end
+	core_defaults.dane = configmanager.get("*", "use_dane") or false;
+	cert_index = index_certs(resolve_path(config_path, global_certificates));
 end
 
 prosody.events.add_handler("config-reloaded", reload_ssl_config);
@@ -266,4 +431,7 @@
 	create_context = create_context;
 	reload_ssl_config = reload_ssl_config;
 	find_cert = find_cert;
+	index_certs = index_certs;
+	find_host_cert = find_host_cert;
+	find_cert_in_index = find_cert_in_index;
 };
--- a/core/configmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/configmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,15 +7,16 @@
 --
 
 local _G = _G;
-local setmetatable, rawget, rawset, io, os, error, dofile, type, pairs =
-      setmetatable, rawget, rawset, io, os, error, dofile, type, pairs;
-local format, math_max = string.format, math.max;
+local setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs =
+      setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs;
+local format, math_max, t_insert = string.format, math.max, table.insert;
 
 local envload = require"util.envload".envload;
 local deps = require"util.dependencies";
 local resolve_relative_path = require"util.paths".resolve_relative_path;
 local glob_to_pattern = require"util.paths".glob_to_pattern;
 local path_sep = package.config:sub(1,1);
+local get_traceback_table = require "util.debug".get_traceback_table;
 
 local encodings = deps.softreq"util.encodings";
 local nameprep = encodings and encodings.stringprep.nameprep or function (host) return host:lower(); end
@@ -30,6 +31,7 @@
 
 local config_mt = { __index = function (t, _) return rawget(t, "*"); end};
 local config = setmetatable({ ["*"] = { } }, config_mt);
+local files = {};
 
 -- When host not found, use global
 local host_mt = { __index = function(_, k) return config["*"][k] end }
@@ -97,11 +99,25 @@
 	end
 end
 
+function _M.files()
+	return files;
+end
+
 -- Built-in Lua parser
 do
 	local pcall = _G.pcall;
+	local function get_line_number(config_file)
+		local tb = get_traceback_table(nil, 2);
+		for i = 1, #tb do
+			if tb[i].info.short_src == config_file then
+				return tb[i].info.currentline;
+			end
+		end
+	end
 	parser = {};
 	function parser.load(data, config_file, config_table)
+		local set_options = {}; -- set_options[host.."/"..option_name] = true (when the option has been set already in this file)
+		local warnings = {};
 		local env;
 		-- The ' = true' are needed so as not to set off __newindex when we assign the functions below
 		env = setmetatable({
@@ -115,13 +131,26 @@
 					return rawget(_G, k);
 				end,
 				__newindex = function (_, k, v)
+					local host = env.__currenthost or "*";
+					local option_path = host.."/"..k;
+					if set_options[option_path] then
+						t_insert(warnings, ("%s:%d: Duplicate option '%s'"):format(config_file, get_line_number(config_file), k));
+					end
+					set_options[option_path] = true;
 					set(config_table, env.__currenthost or "*", k, v);
 				end
 		});
 
 		rawset(env, "__currenthost", "*") -- Default is global
 		function env.VirtualHost(name)
-			name = nameprep(name);
+			if not name then
+				error("Host must have a name", 2);
+			end
+			local prepped_name = nameprep(name);
+			if not prepped_name then
+				error(format("Name of Host %q contains forbidden characters", name), 0);
+			end
+			name = prepped_name;
 			if rawget(config_table, name) and rawget(config_table[name], "component_module") then
 				error(format("Host %q clashes with previously defined %s Component %q, for services use a sub-domain like conference.%s",
 					name, config_table[name].component_module:gsub("^%a+$", { component = "external", muc = "MUC"}), name, name), 0);
@@ -131,6 +160,11 @@
 			set(config_table, name or "*", "defined", true);
 			return function (config_options)
 				rawset(env, "__currenthost", "*"); -- Return to global scope
+				if type(config_options) == "string" then
+					error(format("VirtualHost entries do not accept a module name (module '%s' provided for host '%s')", config_options, name), 2);
+				elseif type(config_options) ~= "table" then
+					error("Invalid syntax following VirtualHost, expected options but received a "..type(config_options), 2);
+				end
 				for option_name, option_value in pairs(config_options) do
 					set(config_table, name or "*", option_name, option_value);
 				end
@@ -139,10 +173,17 @@
 		env.Host, env.host = env.VirtualHost, env.VirtualHost;
 
 		function env.Component(name)
-			name = nameprep(name);
+			if not name then
+				error("Component must have a name", 2);
+			end
+			local prepped_name = nameprep(name);
+			if not prepped_name then
+				error(format("Name of Component %q contains forbidden characters", name), 0);
+			end
+			name = prepped_name;
 			if rawget(config_table, name) and rawget(config_table[name], "defined")
 				and not rawget(config_table[name], "component_module") then
-				error(format("Component %q clashes with previously defined Host %q, for services use a sub-domain like conference.%s",
+				error(format("Component %q clashes with previously defined VirtualHost %q, for services use a sub-domain like conference.%s",
 					name, name, name), 0);
 			end
 			set(config_table, name, "component_module", "component");
@@ -195,6 +236,11 @@
 			if f then
 				local ret, err = parser.load(f:read("*a"), file, config_table);
 				if not ret then error(err:gsub("%[string.-%]", file), 0); end
+				if err then
+					for _, warning in ipairs(err) do
+						t_insert(warnings, warning);
+					end
+				end
 			end
 			if not f then error("Error loading included "..file..": "..err, 0); end
 			return f, err;
@@ -217,7 +263,9 @@
 			return nil, err;
 		end
 
-		return true;
+		t_insert(files, config_file);
+
+		return true, warnings;
 	end
 
 end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/core/features.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,8 @@
+local set = require "util.set";
+
+return {
+	available = set.new{
+		-- mod_bookmarks bundled
+		"mod_bookmarks";
+	};
+};
--- a/core/hostmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/hostmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -133,7 +133,6 @@
 		for remotehost, session in pairs(host_session.s2sout) do
 			if session.close then
 				log("debug", "Closing outgoing connection to %s", remotehost);
-				if session.srv_hosts then session.srv_hosts = nil; end
 				session:close(reason);
 			end
 		end
--- a/core/loggingmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/loggingmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -14,10 +14,14 @@
 local math_max, rep = math.max, string.rep;
 local os_date = os.date;
 local getstyle, getstring = require "util.termcolours".getstyle, require "util.termcolours".getstring;
+local st = require "util.stanza";
 
 local config = require "core.configmanager";
 local logger = require "util.logger";
 
+local have_pposix, pposix = pcall(require, "util.pposix");
+have_pposix = have_pposix and pposix._VERSION == "0.4.0";
+
 local _ENV = nil;
 -- luacheck: std none
 
@@ -33,6 +37,8 @@
 local get_levels;
 local logging_levels = { "debug", "info", "warn", "error" }
 
+local function id(x) return x end
+
 -- Put a rule into action. Requires that the sink type has already been registered.
 -- This function is called automatically when a new sink type is added [see apply_sink_rules()]
 local function add_rule(sink_config)
@@ -181,15 +187,16 @@
 
 	-- Column width for "source" (used by stdout and console)
 	local sourcewidth = sink_config.source_width;
+	local filter = sink_config.filter or id;
 
 	if sourcewidth then
 		return function (name, level, message, ...)
 			sourcewidth = math_max(#name+2, sourcewidth);
-			write(logfile, timestamps and os_date(timestamps) or "", name, rep(" ", sourcewidth-#name), level, "\t", format(message, ...), "\n");
+			write(logfile, timestamps and os_date(timestamps) or "", name, rep(" ", sourcewidth-#name), level, "\t", filter(format(message, ...)), "\n");
 		end
 	else
 		return function (name, level, message, ...)
-			write(logfile, timestamps and os_date(timestamps) or "", name, "\t", level, "\t", format(message, ...), "\n");
+			write(logfile, timestamps and os_date(timestamps) or "", name, "\t", level, "\t", filter(format(message, ...)), "\n");
 		end
 	end
 end
@@ -206,22 +213,26 @@
 end
 log_sink_types.stdout = log_to_stdout;
 
-local do_pretty_printing = true;
+local do_pretty_printing = not have_pposix or pposix.isatty(stdout);
 
-local logstyles;
+local logstyles, pretty;
 if do_pretty_printing then
 	logstyles = {};
 	logstyles["info"] = getstyle("bold");
 	logstyles["warn"] = getstyle("bold", "yellow");
 	logstyles["error"] = getstyle("bold", "red");
+
+	pretty = st.pretty_print;
 end
 
 local function log_to_console(sink_config)
 	-- Really if we don't want pretty colours then just use plain stdout
-	local logstdout = log_to_stdout(sink_config);
+	-- FIXME refactor to allow console logging with colours on stderr
 	if not do_pretty_printing then
-		return logstdout;
+		return log_to_stdout(sink_config);
 	end
+	sink_config.filter = pretty;
+	local logstdout = log_to_stdout(sink_config);
 	return function (name, level, message, ...)
 		local logstyle = logstyles[level];
 		if logstyle then
@@ -232,6 +243,22 @@
 end
 log_sink_types.console = log_to_console;
 
+if have_pposix then
+	local syslog_opened;
+	local function log_to_syslog(sink_config) -- luacheck: ignore 212/sink_config
+		if not syslog_opened then
+			local facility = sink_config.syslog_facility or config.get("*", "syslog_facility");
+			pposix.syslog_open(sink_config.syslog_name or "prosody", facility);
+			syslog_opened = true;
+		end
+		local syslog = pposix.syslog_log;
+		return function (name, level, message, ...)
+			syslog(level, name, format(message, ...));
+		end;
+	end
+	log_sink_types.syslog = log_to_syslog;
+end
+
 local function register_sink_type(name, sink_maker)
 	local old_sink_maker = log_sink_types[name];
 	log_sink_types[name] = sink_maker;
--- a/core/moduleapi.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/moduleapi.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,17 +10,23 @@
 local set = require "util.set";
 local it = require "util.iterators";
 local logger = require "util.logger";
-local pluginloader = require "util.pluginloader";
 local timer = require "util.timer";
 local resolve_relative_path = require"util.paths".resolve_relative_path;
 local st = require "util.stanza";
+local cache = require "util.cache";
+local errors = require "util.error";
+local promise = require "util.promise";
+local time_now = require "util.time".now;
+local format = require "util.format".format;
+local jid_node = require "util.jid".node;
+local jid_resource = require "util.jid".resource;
 
 local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
 local error, setmetatable, type = error, setmetatable, type;
 local ipairs, pairs, select = ipairs, pairs, select;
 local tonumber, tostring = tonumber, tostring;
 local require = require;
-local pack = table.pack or function(...) return {n=select("#",...), ...}; end -- table.pack is only in 5.2
+local pack = table.pack or require "util.table".pack; -- table.pack is only in 5.2
 local unpack = table.unpack or unpack; --luacheck: ignore 113 -- renamed in 5.2
 
 local prosody = prosody;
@@ -97,7 +103,7 @@
 		-- If only 2 options then they specified no xmlns
 		xmlns, name, handler, priority = nil, xmlns, name, handler;
 	elseif not (handler and name) then
-		self:log("warn", "Error: Insufficient parameters to module:hook_stanza()");
+		self:log("warn", "Error: Insufficient parameters to module:hook_tag()");
 		return;
 	end
 	return self:hook("stanza/"..(xmlns and (xmlns..":") or "")..name,
@@ -122,7 +128,8 @@
 end
 
 function api:require(lib)
-	local f, n = pluginloader.load_code_ext(self.name, lib, "lib.lua", self.environment);
+	local modulemanager = require"core.modulemanager";
+	local f, n = modulemanager.loader:load_code_ext(self.name, lib, "lib.lua", self.environment);
 	if not f then error("Failed to load plugin library '"..lib.."', error: "..n); end -- FIXME better error message
 	return f();
 end
@@ -300,7 +307,7 @@
 
 
 function api:context(host)
-	return setmetatable({host=host or "*"}, {__index=self,__newindex=self});
+	return setmetatable({ host = host or "*", global = "*" == host }, { __index = self, __newindex = self });
 end
 
 function api:add_item(key, value)
@@ -348,7 +355,7 @@
 		local item_name = self.name;
 		-- Strip a provider prefix to find the item name
 		-- (e.g. "auth_foo" -> "foo" for an auth provider)
-		if item_name:find(name.."_", 1, true) == 1 then
+		if item_name:find((name:gsub("%-", "_")).."_", 1, true) == 1 then
 			item_name = item_name:sub(#name+2);
 		end
 		item.name = item_name;
@@ -361,6 +368,111 @@
 	return core_post_stanza(origin or hosts[self.host], stanza);
 end
 
+function api:send_iq(stanza, origin, timeout)
+	local iq_cache = self._iq_cache;
+	if not iq_cache then
+		iq_cache = cache.new(256, function (_, iq)
+			iq.reject(errors.new({
+				type = "wait", condition = "resource-constraint",
+				text = "evicted from iq tracking cache"
+			}));
+		end);
+		self._iq_cache = iq_cache;
+	end
+
+	local event_type;
+	if not jid_node(stanza.attr.from) then
+		event_type = "host";
+	elseif jid_resource(stanza.attr.from) then
+		event_type = "full";
+	else -- assume bare since we can't hook full jids
+		event_type = "bare";
+	end
+	local result_event = "iq-result/"..event_type.."/"..stanza.attr.id;
+	local error_event = "iq-error/"..event_type.."/"..stanza.attr.id;
+	local cache_key = event_type.."/"..stanza.attr.id;
+	if event_type == "full" then
+		result_event = "iq/" .. event_type;
+		error_event = "iq/" .. event_type;
+	end
+
+	local p = promise.new(function (resolve, reject)
+		local function result_handler(event)
+			local response = event.stanza;
+			if response.attr.type == "result" and response.attr.from == stanza.attr.to and response.attr.id == stanza.attr.id then
+				resolve(event);
+				return true;
+			end
+		end
+
+		local function error_handler(event)
+			local response = event.stanza;
+			if response.attr.type == "error" and response.attr.from == stanza.attr.to and response.attr.id == stanza.attr.id then
+				reject(errors.from_stanza(event.stanza, event));
+				return true;
+			end
+		end
+
+		if iq_cache:get(cache_key) then
+			reject(errors.new({
+				type = "modify", condition = "conflict",
+				text = "IQ stanza id attribute already used",
+			}));
+			return;
+		end
+
+		self:hook(result_event, result_handler, 1);
+		self:hook(error_event, error_handler, 1);
+
+		local timeout_handle = self:add_timer(timeout or 120, function ()
+			reject(errors.new({
+				type = "wait", condition = "remote-server-timeout",
+				text = "IQ stanza timed out",
+			}));
+		end);
+
+		local ok = iq_cache:set(cache_key, {
+			reject = reject, resolve = resolve,
+			timeout_handle = timeout_handle,
+			result_handler = result_handler, error_handler = error_handler;
+		});
+
+		if not ok then
+			reject(errors.new({
+				type = "wait", condition = "internal-server-error",
+				text = "Could not store IQ tracking data"
+			}));
+			return;
+		end
+
+		local wrapped_origin = setmetatable({
+				-- XXX Needed in some cases for replies to work correctly when sending queries internally.
+				send = function (reply)
+					if reply.name == stanza.name and reply.attr.id == stanza.attr.id then
+						resolve({ stanza = reply });
+					end
+					return (origin or hosts[self.host]).send(reply)
+				end;
+			}, {
+				__index = origin or hosts[self.host];
+			});
+
+		self:send(stanza, wrapped_origin);
+	end);
+
+	p:finally(function ()
+		local iq = iq_cache:get(cache_key);
+		if iq then
+			self:unhook(result_event, iq.result_handler);
+			self:unhook(error_event, iq.error_handler);
+			iq.timeout_handle:stop();
+			iq_cache:set(cache_key, nil);
+		end
+	end);
+
+	return p;
+end
+
 function api:broadcast(jids, stanza, iter)
 	for jid in (iter or it.values)(jids) do
 		local new_stanza = st.clone(stanza);
@@ -394,9 +506,29 @@
 	return setmetatable(t, timer_mt);
 end
 
+function api:cron(task_spec)
+	self:depends("cron");
+	self:add_item("task", task_spec);
+end
+
+function api:hourly(name, fun)
+	if type(name) == "function" then fun, name = name, nil; end
+	self:cron({ name = name; when = "hourly"; run = fun });
+end
+
+function api:daily(name, fun)
+	if type(name) == "function" then fun, name = name, nil; end
+	self:cron({ name = name; when = "daily"; run = fun });
+end
+
+function api:weekly(name, fun)
+	if type(name) == "function" then fun, name = name, nil; end
+	self:cron({ name = name; when = "weekly"; run = fun });
+end
+
 local path_sep = package.config:sub(1,1);
 function api:get_directory()
-	return self.path and (self.path:gsub("%"..path_sep.."[^"..path_sep.."]*$", "")) or nil;
+	return self.resource_path or self.path and (self.path:gsub("%"..path_sep.."[^"..path_sep.."]*$", "")) or nil;
 end
 
 function api:load_resource(path, mode)
@@ -408,28 +540,65 @@
 	return require"core.storagemanager".open(self.host, name or self.name, store_type);
 end
 
-function api:measure(name, stat_type)
+function api:measure(name, stat_type, conf)
 	local measure = require "core.statsmanager".measure;
-	return measure(stat_type, "/"..self.host.."/mod_"..self.name.."/"..name);
+	local fixed_label_key, fixed_label_value
+	if self.host ~= "*" then
+		fixed_label_key = "host"
+		fixed_label_value = self.host
+	end
+	-- new_legacy_metric takes care of scoping for us, as it does not accept
+	-- an array of labels
+	-- the prosody_ prefix is automatically added by statsmanager for legacy
+	-- metrics.
+	self:add_item("measure", { name = name, type = stat_type, conf = conf });
+	return measure(stat_type, "mod_"..self.name.."/"..name, conf, fixed_label_key, fixed_label_value)
 end
 
-function api:measure_object_event(events_object, event_name, stat_name)
-	local m = self:measure(stat_name or event_name, "times");
-	local function handler(handlers, _event_name, _event_data)
-		local finished = m();
-		local ret = handlers(_event_name, _event_data);
-		finished();
-		return ret;
+function api:metric(type_, name, unit, description, label_keys, conf)
+	local metric = require "core.statsmanager".metric;
+	local is_scoped = self.host ~= "*"
+	if is_scoped then
+		-- prepend `host` label to label keys if this is not a global module
+		local orig_labels = label_keys
+		label_keys = array { "host" }
+		label_keys:append(orig_labels)
 	end
-	return self:hook_object_event(events_object, event_name, handler);
+	local mf = metric(type_, "prosody_mod_"..self.name.."/"..name, unit, description, label_keys, conf)
+	self:add_item("metric", { name = name, mf = mf });
+	if is_scoped then
+		-- make sure to scope the returned metric family to the current host
+		return mf:with_partial_label(self.host)
+	end
+	return mf
 end
 
-function api:measure_event(event_name, stat_name)
-	return self:measure_object_event((hosts[self.host] or prosody).events.wrappers, event_name, stat_name);
+local status_priorities = { error = 3, warn = 2, info = 1, core = 0 };
+
+function api:set_status(status_type, status_message, override)
+	local priority = status_priorities[status_type];
+	if not priority then
+		self:log("error", "set_status: Invalid status type '%s', assuming 'info'");
+		status_type, priority = "info", status_priorities.info;
+	end
+	local current_priority = status_priorities[self.status_type] or 0;
+	-- By default an 'error' status can only be overwritten by another 'error' status
+	if (current_priority >= status_priorities.error and priority < current_priority and override ~= true)
+	or (override == false and current_priority > priority) then
+		self:log("debug", "moduleapi: ignoring status [prio %d override %s]: %s", priority, override, status_message);
+		return;
+	end
+	self.status_type, self.status_message, self.status_time = status_type, status_message, time_now();
+	self:fire_event("module-status/updated", { name = self.name });
 end
 
-function api:measure_global_event(event_name, stat_name)
-	return self:measure_object_event(prosody.events.wrappers, event_name, stat_name);
+function api:log_status(level, msg, ...)
+	self:set_status(level, format(msg, ...));
+	return self:log(level, msg, ...);
+end
+
+function api:get_status()
+	return self.status_type, self.status_message, self.status_time;
 end
 
 return api;
--- a/core/modulemanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/modulemanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -6,12 +6,16 @@
 -- COPYING file in the source package for more information.
 --
 
+local array = require "util.array";
 local logger = require "util.logger";
 local log = logger.init("modulemanager");
 local config = require "core.configmanager";
 local pluginloader = require "util.pluginloader";
+local envload = require "util.envload";
 local set = require "util.set";
 
+local core_features = require "core.features".available;
+
 local new_multitable = require "util.multitable".new;
 local api = require "core.moduleapi"; -- Module API container
 
@@ -22,9 +26,28 @@
 local debug_traceback = debug.traceback;
 local setmetatable, rawget = setmetatable, rawget;
 local ipairs, pairs, type, t_insert = ipairs, pairs, type, table.insert;
+local lua_version = _VERSION:match("5%.%d$");
 
-local autoload_modules = {prosody.platform, "presence", "message", "iq", "offline", "c2s", "s2s", "s2s_auth_certs"};
-local component_inheritable_modules = {"tls", "saslauth", "dialback", "iq", "s2s"};
+local autoload_modules = {
+	prosody.platform,
+	"presence",
+	"message",
+	"iq",
+	"offline",
+	"c2s",
+	"s2s",
+	"s2s_auth_certs",
+};
+local component_inheritable_modules = {
+	"tls",
+	"saslauth",
+	"dialback",
+	"iq",
+	"s2s",
+	"s2s_bidi",
+	"smacks",
+	"server_contact_info",
+};
 
 -- We need this to let modules access the real global namespace
 local _G = _G;
@@ -32,6 +55,38 @@
 local _ENV = nil;
 -- luacheck: std none
 
+local loader = pluginloader.init({
+	load_filter_cb = function (path, content)
+		local metadata = {};
+		for line in content:gmatch("([^\r\n]+)\r?\n") do
+			local key, value = line:match("^%-%-%% *([%w_]+): *(.+)$");
+			if key then
+				value = value:gsub("%s+$", "");
+				metadata[key] = value;
+			end
+		end
+
+		if metadata.conflicts then
+			local conflicts_features = set.new(array.collect(metadata.conflicts:gmatch("[^, ]+")));
+			local conflicted_features = set.intersection(conflicts_features, core_features);
+			if not conflicted_features:empty() then
+				log("warn", "Not loading module, due to conflicting features '%s': %s", conflicted_features, path);
+				return; -- Don't load this module
+			end
+		end
+		if metadata.requires then
+			local required_features = set.new(array.collect(metadata.requires:gmatch("[^, ]+")));
+			local missing_features = required_features - core_features;
+			if not missing_features:empty() then
+				log("warn", "Not loading module, due to missing features '%s': %s", missing_features, path);
+				return; -- Don't load this module
+			end
+		end
+
+		return path, content, metadata;
+	end;
+});
+
 local load_modules_for_host, load, unload, reload, get_module, get_items;
 local get_modules, is_loaded, module_has_method, call_module_method;
 
@@ -56,13 +111,6 @@
 	end
 	local modules = (global_modules + set.new(host_modules_enabled)) - set.new(host_modules_disabled);
 
-	-- COMPAT w/ pre 0.8
-	if modules:contains("console") then
-		log("error", "The mod_console plugin has been renamed to mod_admin_telnet. Please update your config.");
-		modules:remove("console");
-		modules:add("admin_telnet");
-	end
-
 	if modules:contains("vcard") and modules:contains("vcard_legacy") then
 		log("error", "The mod_vcard_legacy plugin replaces mod_vcard but both are enabled. Please update your config.");
 		modules:remove("vcard");
@@ -141,6 +189,7 @@
 		if module_has_method(mod, "add_host") then
 			local _log = logger.init(host..":"..module_name);
 			local host_module_api = setmetatable({
+				global = false,
 				host = host, event_handlers = new_multitable(), items = {};
 				_log = _log, log = function (self, ...) return _log(...); end; --luacheck: ignore 212/self
 			},{
@@ -171,13 +220,51 @@
 	local pluginenv = setmetatable({ module = api_instance }, { __index = _G });
 	api_instance.environment = pluginenv;
 
-	local mod, err = pluginloader.load_code(module_name, nil, pluginenv);
+	local mod, err, meta = loader:load_code(module_name, nil, pluginenv);
 	if not mod then
 		log("error", "Unable to load module '%s': %s", module_name or "nil", err or "nil");
+		api_instance:set_status("error", "Failed to load (see log)");
 		return nil, err;
 	end
 
 	api_instance.path = err;
+	api_instance.meta = meta;
+
+	local custom_plugins = prosody.paths.installer;
+	if custom_plugins and err:sub(1, #custom_plugins+1) == custom_plugins.."/" then
+		-- Stage 1: Make it work (you are here)
+		-- Stage 2: Make it less hacky (TODO)
+		local manifest = {};
+		local luarocks_path = custom_plugins.."/lib/luarocks/rocks-"..lua_version;
+		local manifest_filename = luarocks_path.."/manifest";
+		local load_manifest, err = envload.envloadfile(manifest_filename, manifest);
+		if not load_manifest then
+			-- COMPAT Luarocks 2.x
+			log("debug", "Could not load LuaRocks 3.x manifest, trying 2.x", err);
+			luarocks_path = custom_plugins.."/lib/luarocks/rocks";
+			manifest_filename = luarocks_path.."/manifest";
+			load_manifest, err = envload.envloadfile(manifest_filename, manifest);
+		end
+		if not load_manifest then
+			log("error", "Could not load manifest of installed plugins: %s", err, load_manifest);
+		else
+			local ok, err = xpcall(load_manifest, debug_traceback);
+			if not ok then
+				log("error", "Could not load manifest of installed plugins: %s", err);
+			elseif type(manifest.modules) ~= "table" then
+				log("debug", "Expected 'table' but manifest.modules = %q", manifest.modules);
+				log("error", "Can't look up resource path for mod_%s because '%s' does not appear to be a LuaRocks manifest", module_name, manifest_filename);
+			else
+				local versions = manifest.modules["mod_"..module_name];
+				if type(versions) == "table" and versions[1] then
+					-- Not going to deal with multiple installed versions
+					api_instance.resource_path = luarocks_path.."/"..versions[1];
+				else
+					log("debug", "mod_%s does not appear in the installation manifest", module_name);
+				end
+			end
+		end
+	end
 
 	modulemap[host][module_name] = pluginenv;
 	local ok, err = xpcall(mod, debug_traceback);
@@ -187,6 +274,7 @@
 			ok, err = call_module_method(pluginenv, "load");
 			if not ok then
 				log("warn", "Error loading module '%s' on '%s': %s", module_name, host, err or "nil");
+				api_instance:set_status("warn", "Error during load (see log)");
 			end
 		end
 		api_instance.reloading, api_instance.saved_state = nil, nil;
@@ -209,6 +297,9 @@
 	if not ok then
 		modulemap[api_instance.host][module_name] = nil;
 		log("error", "Error initializing module '%s' on '%s': %s", module_name, host, err or "nil");
+		api_instance:set_status("warn", "Error during load (see log)");
+	else
+		api_instance:set_status("core", "Loaded", false);
 	end
 	return ok and pluginenv, err;
 end
@@ -217,7 +308,7 @@
 	local mod = get_module(host, name);
 	if not mod then return nil, "module-not-loaded"; end
 
-	local _mod, err = pluginloader.load_code(name); -- checking for syntax errors
+	local _mod, err = loader:load_code(name); -- checking for syntax errors
 	if not _mod then
 		log("error", "Unable to load module '%s': %s", name or "nil", err or "nil");
 		return nil, err;
@@ -225,7 +316,8 @@
 
 	local saved;
 	if module_has_method(mod, "save") then
-		local ok, ret, err = call_module_method(mod, "save");
+		-- FIXME What goes in 'err' here?
+		local ok, ret, err = call_module_method(mod, "save"); -- luacheck: ignore 211/err
 		if ok then
 			saved = ret;
 		else
@@ -340,4 +432,6 @@
 	is_loaded = is_loaded;
 	module_has_method = module_has_method;
 	call_module_method = call_module_method;
+
+	loader = loader;
 };
--- a/core/portmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/portmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,7 +9,8 @@
 
 local table = table;
 local setmetatable, rawset, rawget = setmetatable, rawset, rawget;
-local type, tonumber, tostring, ipairs = type, tonumber, tostring, ipairs;
+local type, tonumber, ipairs = type, tonumber, ipairs;
+local pairs = pairs;
 
 local prosody = prosody;
 local fire_event = prosody.events.fire_event;
@@ -64,6 +65,20 @@
 	return friendly_message;
 end
 
+local function get_port_ssl_ctx(port, interface, config_prefix, service_info)
+	local global_ssl_config = config.get("*", "ssl") or {};
+	local prefix_ssl_config = config.get("*", config_prefix.."ssl") or global_ssl_config;
+	log("debug", "Creating context for direct TLS service %s on port %d", service_info.name, port);
+	local ssl, err, cfg = certmanager.create_context(service_info.name.." port "..port, "server",
+		prefix_ssl_config[interface],
+		prefix_ssl_config[port],
+		prefix_ssl_config,
+		service_info.ssl_config or {},
+		global_ssl_config[interface],
+		global_ssl_config[port]);
+	return ssl, cfg, err;
+end
+
 --- Public API
 
 local function activate(service_name)
@@ -95,31 +110,22 @@
 		   }
 	bind_ports = set.new(type(bind_ports) ~= "table" and { bind_ports } or bind_ports );
 
-	local mode, ssl = listener.default_mode or default_mode;
+	local mode = listener.default_mode or default_mode;
 	local hooked_ports = {};
 
 	for interface in bind_interfaces do
 		for port in bind_ports do
 			local port_number = tonumber(port);
 			if not port_number then
-				log("error", "Invalid port number specified for service '%s': %s", service_info.name, tostring(port));
+				log("error", "Invalid port number specified for service '%s': %s", service_info.name, port);
 			elseif #active_services:search(nil, interface, port_number) > 0 then
 				log("error", "Multiple services configured to listen on the same port ([%s]:%d): %s, %s", interface, port,
 					active_services:search(nil, interface, port)[1][1].service.name or "<unnamed>", service_name or "<unnamed>");
 			else
-				local err;
+				local ssl, cfg, err;
 				-- Create SSL context for this service/port
 				if service_info.encryption == "ssl" then
-					local global_ssl_config = config.get("*", "ssl") or {};
-					local prefix_ssl_config = config.get("*", config_prefix.."ssl") or global_ssl_config;
-					log("debug", "Creating context for direct TLS service %s on port %d", service_info.name, port);
-					ssl, err = certmanager.create_context(service_info.name.." port "..port, "server",
-						prefix_ssl_config[interface],
-						prefix_ssl_config[port],
-						prefix_ssl_config,
-						service_info.ssl_config or {},
-						global_ssl_config[interface],
-						global_ssl_config[port]);
+					ssl, cfg, err = get_port_ssl_ctx(port, interface, config_prefix, service_info);
 					if not ssl then
 						log("error", "Error binding encrypted port for %s: %s", service_info.name,
 							error_to_friendly_message(service_name, port_number, err) or "unknown error");
@@ -127,7 +133,12 @@
 				end
 				if not err then
 					-- Start listening on interface+port
-					local handler, err = server.addserver(interface, port_number, listener, mode, ssl);
+					local handler, err = server.listen(interface, port_number, listener, {
+						read_size = mode,
+						tls_ctx = ssl,
+						tls_direct = service_info.encryption == "ssl";
+						sni_hosts = {},
+					});
 					if not handler then
 						log("error", "Failed to open server port %d on %s, %s", port_number, interface,
 							error_to_friendly_message(service_name, port_number, err));
@@ -137,6 +148,7 @@
 						active_services:add(service_name, interface, port_number, {
 							server = handler;
 							service = service_info;
+							tls_cfg = cfg;
 						});
 					end
 				end
@@ -163,7 +175,7 @@
 local function register_service(service_name, service_info)
 	table.insert(services[service_name], service_info);
 
-	if not active_services:get(service_name) then
+	if not active_services:get(service_name) and prosody.process_type == "prosody" then
 		log("debug", "No active service for %s, activating...", service_name);
 		local ok, err = activate(service_name);
 		if not ok then
@@ -204,7 +216,9 @@
 end
 
 function get_service_at(interface, port)
-	local data = active_services:search(nil, interface, port)[1][1];
+	local data = active_services:search(nil, interface, port);
+	if not data or not data[1] or not data[1][1] then return nil, "not-found"; end
+	data = data[1][1];
 	return data.service, data.server;
 end
 
@@ -222,15 +236,75 @@
 
 -- Event handlers
 
+local function add_sni_host(host, service)
+	log("debug", "Gathering certificates for SNI for host %s, %s service", host, service or "default");
+	for name, interface, port, n, active_service --luacheck: ignore 213
+		in active_services:iter(service, nil, nil, nil) do
+		if active_service.server.hosts and active_service.tls_cfg then
+			local config_prefix = (active_service.config_prefix or name).."_";
+			if config_prefix == "_" then config_prefix = ""; end
+			local prefix_ssl_config = config.get(host, config_prefix.."ssl");
+			local alternate_host = name and config.get(host, name.."_host");
+			if not alternate_host and name == "https" then
+				-- TODO should this be some generic thing? e.g. in the service definition
+				alternate_host = config.get(host, "http_host");
+			end
+			local autocert = certmanager.find_host_cert(alternate_host or host);
+			-- luacheck: ignore 211/cfg
+			local ssl, err, cfg = certmanager.create_context(host, "server", prefix_ssl_config, autocert, active_service.tls_cfg);
+			if ssl then
+				active_service.server.hosts[alternate_host or host] = ssl;
+			else
+				log("error", "Error creating TLS context for SNI host %s: %s", host, err);
+			end
+		end
+	end
+end
 prosody.events.add_handler("item-added/net-provider", function (event)
 	local item = event.item;
 	register_service(item.name, item);
+	for host in pairs(prosody.hosts) do
+		add_sni_host(host, item.name);
+	end
 end);
 prosody.events.add_handler("item-removed/net-provider", function (event)
 	local item = event.item;
 	unregister_service(item.name, item);
 end);
 
+prosody.events.add_handler("host-activated", add_sni_host);
+prosody.events.add_handler("host-deactivated", function (host)
+	for name, interface, port, n, active_service --luacheck: ignore 213
+		in active_services:iter(nil, nil, nil, nil) do
+		if active_service.tls_cfg then
+			active_service.server.hosts[host] = nil;
+		end
+	end
+end);
+
+prosody.events.add_handler("config-reloaded", function ()
+	for service_name, interface, port, _, active_service in active_services:iter(nil, nil, nil, nil) do
+		if active_service.tls_cfg then
+			local service_info = active_service.service;
+			local config_prefix = (service_info.config_prefix or service_name).."_";
+			if config_prefix == "_" then
+				config_prefix = "";
+			end
+			local ssl, cfg, err = get_port_ssl_ctx(port, interface, config_prefix, service_info);
+			if ssl then
+				active_service.server:set_sslctx(ssl);
+				active_service.tls_cfg = cfg;
+			else
+				log("error", "Error reloading certificate for encrypted port for %s: %s", service_info.name,
+					error_to_friendly_message(service_name, port, err) or "unknown error");
+			end
+		end
+	end
+	for host in pairs(prosody.hosts) do
+		add_sni_host(host, nil);
+	end
+end, -1);
+
 return {
 	activate = activate;
 	deactivate = deactivate;
--- a/core/rostermanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/rostermanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -139,7 +139,7 @@
 		-- Due to map store use, we need to manually delete this entry
 		log("debug", "Removing legacy 'pending' entry");
 		if not save_roster(username, host, roster, "pending") then
-			log("warn", "Could not remove legacy 'pendig' entry");
+			log("warn", "Could not remove legacy 'pending' entry");
 		end
 	end
 	if roster[jid] then
@@ -285,15 +285,15 @@
 
 function is_contact_pending_in(username, host, jid)
 	local roster = load_roster(username, host);
-	return roster[false].pending[jid];
+	return roster[false].pending[jid] ~= nil;
 end
-local function set_contact_pending_in(username, host, jid)
+local function set_contact_pending_in(username, host, jid, stanza)
 	local roster = load_roster(username, host);
 	local item = roster[jid];
 	if item and (item.subscription == "from" or item.subscription == "both") then
 		return; -- false
 	end
-	roster[false].pending[jid] = true;
+	roster[false].pending[jid] = st.is_stanza(stanza) and st.preserialize(stanza) or true;
 	return save_roster(username, host, roster, jid);
 end
 function is_contact_pending_out(username, host, jid)
@@ -301,6 +301,11 @@
 	local item = roster[jid];
 	return item and item.ask;
 end
+local function is_contact_preapproved(username, host, jid)
+	local roster = load_roster(username, host);
+	local item = roster[jid];
+	return item and (item.approved == "true");
+end
 local function set_contact_pending_out(username, host, jid) -- subscribe
 	local roster = load_roster(username, host);
 	local item = roster[jid];
@@ -331,9 +336,10 @@
 	return save_roster(username, host, roster, jid);
 end
 local function subscribed(username, host, jid)
+	local roster = load_roster(username, host);
+	local item = roster[jid];
+
 	if is_contact_pending_in(username, host, jid) then
-		local roster = load_roster(username, host);
-		local item = roster[jid];
 		if not item then -- FIXME should roster item be auto-created?
 			item = {subscription = "none", groups = {}};
 			roster[jid] = item;
@@ -345,7 +351,17 @@
 		end
 		roster[false].pending[jid] = nil;
 		return save_roster(username, host, roster, jid);
-	end -- TODO else implement optional feature pre-approval (ask = subscribed)
+	elseif not item or item.subscription == "none" or item.subscription == "to" then
+		-- Contact is not subscribed and has not sent a subscription request.
+		-- We store a pre-approval as per RFC6121 3.4
+		if not item then
+			item = {subscription = "none", groups = {}};
+			roster[jid] = item;
+		end
+		item.approved = "true";
+		log("debug", "Storing preapproval for %s", jid);
+		return save_roster(username, host, roster, jid);
+	end
 end
 local function unsubscribed(username, host, jid)
 	local roster = load_roster(username, host);
@@ -403,6 +419,7 @@
 	set_contact_pending_in = set_contact_pending_in;
 	is_contact_pending_out = is_contact_pending_out;
 	set_contact_pending_out = set_contact_pending_out;
+	is_contact_preapproved = is_contact_preapproved;
 	unsubscribe = unsubscribe;
 	subscribed = subscribed;
 	unsubscribed = unsubscribed;
--- a/core/s2smanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/s2smanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,10 +9,10 @@
 
 
 local hosts = prosody.hosts;
-local tostring, pairs, setmetatable
-    = tostring, pairs, setmetatable;
+local pairs, setmetatable = pairs, setmetatable;
 
 local logger_init = require "util.logger".init;
+local sessionlib = require "util.session";
 
 local log = logger_init("s2smanager");
 
@@ -26,30 +26,45 @@
 -- luacheck: std none
 
 local function new_incoming(conn)
-	local session = { conn = conn, type = "s2sin_unauthed", direction = "incoming", hosts = {} };
-	session.log = logger_init("s2sin"..tostring(session):match("[a-f0-9]+$"));
-	incoming_s2s[session] = true;
-	return session;
+	local host_session = sessionlib.new("s2sin");
+	sessionlib.set_id(host_session);
+	sessionlib.set_logger(host_session);
+	sessionlib.set_conn(host_session, conn);
+	host_session.direction = "incoming";
+	host_session.incoming = true;
+	host_session.hosts = {};
+	incoming_s2s[host_session] = true;
+	return host_session;
 end
 
 local function new_outgoing(from_host, to_host)
-	local host_session = { to_host = to_host, from_host = from_host, host = from_host,
-		               notopen = true, type = "s2sout_unauthed", direction = "outgoing" };
+	local host_session = sessionlib.new("s2sout");
+	sessionlib.set_id(host_session);
+	sessionlib.set_logger(host_session);
+	host_session.to_host = to_host;
+	host_session.from_host = from_host;
+	host_session.host = from_host;
+	host_session.notopen = true;
+	host_session.direction = "outgoing";
+	host_session.outgoing = true;
+	host_session.hosts = {};
 	hosts[from_host].s2sout[to_host] = host_session;
-	local conn_name = "s2sout"..tostring(host_session):match("[a-f0-9]*$");
-	host_session.log = logger_init(conn_name);
 	return host_session;
 end
 
 local resting_session = { -- Resting, not dead
 		destroyed = true;
 		type = "s2s_destroyed";
+		direction = "destroyed";
 		open_stream = function (session)
 			session.log("debug", "Attempt to open stream on resting session");
 		end;
 		close = function (session)
 			session.log("debug", "Attempt to close already-closed session");
 		end;
+		reset_stream = function (session)
+			session.log("debug", "Attempt to reset stream of already-closed session");
+		end;
 		filter = function (type, data) return data; end; --luacheck: ignore 212/type
 	}; resting_session.__index = resting_session;
 
@@ -63,27 +78,30 @@
 
 	session.destruction_reason = reason;
 
-	function session.send(data) log("debug", "Discarding data sent to resting session: %s", tostring(data)); end
-	function session.data(data) log("debug", "Discarding data received from resting session: %s", tostring(data)); end
+	function session.send(data) log("debug", "Discarding data sent to resting session: %s", data); end
+	function session.data(data) log("debug", "Discarding data received from resting session: %s", data); end
 	session.thread = { run = function (_, data) return session.data(data) end };
 	session.sends2s = session.send;
 	return setmetatable(session, resting_session);
 end
 
-local function destroy_session(session, reason)
+local function destroy_session(session, reason, bounce_reason)
 	if session.destroyed then return; end
-	(session.log or log)("debug", "Destroying "..tostring(session.direction)
-		.." session "..tostring(session.from_host).."->"..tostring(session.to_host)
-		..(reason and (": "..reason) or ""));
+	local log = session.log or log;
+	log("debug", "Destroying %s session %s->%s%s%s", session.direction, session.from_host, session.to_host, reason and ": " or "", reason or "");
 
 	if session.direction == "outgoing" then
 		hosts[session.from_host].s2sout[session.to_host] = nil;
-		session:bounce_sendq(reason);
+		session:bounce_sendq(bounce_reason or reason);
 	elseif session.direction == "incoming" then
+		if session.outgoing and hosts[session.to_host].s2sout[session.from_host] == session then
+			hosts[session.to_host].s2sout[session.from_host] = nil;
+		end
 		incoming_s2s[session] = nil;
 	end
 
 	local event_data = { session = session, reason = reason };
+	fire_event("s2s-destroyed", event_data);
 	if session.type == "s2sout" then
 		fire_event("s2sout-destroyed", event_data);
 		if hosts[session.from_host] then
--- a/core/sessionmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/sessionmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -21,6 +21,7 @@
 local resourceprep = require "util.encodings".stringprep.resourceprep;
 local nodeprep = require "util.encodings".stringprep.nodeprep;
 local generate_identifier = require "util.id".short;
+local sessionlib = require "util.session";
 
 local initialize_filters = require "util.filters".initialize;
 local gettime = require "socket".gettime;
@@ -29,23 +30,34 @@
 -- luacheck: std none
 
 local function new_session(conn)
-	local session = { conn = conn, type = "c2s_unauthed", conntime = gettime() };
+	local session = sessionlib.new("c2s");
+	sessionlib.set_id(session);
+	sessionlib.set_logger(session);
+	sessionlib.set_conn(session, conn);
+
+	session.conntime = gettime();
 	local filter = initialize_filters(session);
 	local w = conn.write;
+
+	function session.rawsend(t)
+		t = filter("bytes/out", tostring(t));
+		if t then
+			local ret, err = w(conn, t);
+			if not ret then
+				session.log("debug", "Error writing to connection: %s", err);
+				return false, err;
+			end
+		end
+		return true;
+	end
+
 	session.send = function (t)
 		session.log("debug", "Sending[%s]: %s", session.type, 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
-				local ret, err = w(conn, t);
-				if not ret then
-					session.log("debug", "Error writing to connection: %s", tostring(err));
-					return false, err;
-				end
-			end
+			return session.rawsend(t);
 		end
 		return true;
 	end
@@ -73,8 +85,9 @@
 		end
 	end
 
-	function session.send(data) log("debug", "Discarding data sent to resting session: %s", tostring(data)); return false; end
-	function session.data(data) log("debug", "Discarding data received from resting session: %s", tostring(data)); end
+	function session.send(data) log("debug", "Discarding data sent to resting session: %s", data); return false; end
+	function session.rawsend(data) log("debug", "Discarding data sent to resting session: %s", data); return false; end
+	function session.data(data) log("debug", "Discarding data received from resting session: %s", data); end
 	session.thread = { run = function (_, data) return session.data(data) end };
 	return setmetatable(session, resting_session);
 end
@@ -110,14 +123,15 @@
 	retire_session(session);
 end
 
-local function make_authenticated(session, username)
+local function make_authenticated(session, username, scope)
 	username = nodeprep(username);
 	if not username or #username == 0 then return nil, "Invalid username"; end
 	session.username = username;
 	if session.type == "c2s_unauthed" then
 		session.type = "c2s_unbound";
 	end
-	session.log("info", "Authenticated as %s@%s", username or "(unknown)", session.host or "(unknown)");
+	session.auth_scope = scope;
+	session.log("info", "Authenticated as %s@%s", username, session.host or "(unknown)");
 	return true;
 end
 
@@ -138,7 +152,7 @@
 		resource = event_payload.resource;
 	end
 
-	resource = resourceprep(resource);
+	resource = resourceprep(resource or "", true);
 	resource = resource ~= "" and resource or generate_identifier();
 	--FIXME: Randomly-generated resources must be unique per-user, and never conflict with existing
 
--- a/core/stanza_router.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/stanza_router.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,6 +12,7 @@
 local tostring = tostring;
 local st = require "util.stanza";
 local jid_split = require "util.jid".split;
+local jid_host = require "util.jid".host;
 local jid_prepped_split = require "util.jid".prepped_split;
 
 local full_sessions = _G.prosody.full_sessions;
@@ -27,7 +28,7 @@
 		local st_type = stanza.attr.type;
 		if st_type == "error" or (name == "iq" and st_type == "result") then
 			if st_type == "error" then
-				local err_type, err_condition, err_message = stanza:get_error();
+				local err_type, err_condition, err_message = stanza:get_error(); -- luacheck: ignore 211/err_message
 				log("debug", "Discarding unhandled error %s (%s, %s) from %s: %s",
 					name, err_type, err_condition or "unknown condition", origin_type, stanza:top_tag());
 			else
@@ -81,7 +82,7 @@
 	local to_bare, from_bare;
 	if to then
 		if full_sessions[to] or bare_sessions[to] or hosts[to] then
-			node, host = jid_split(to); -- TODO only the host is needed, optimize
+			host = jid_host(to);
 		else
 			node, host, resource = jid_prepped_split(to);
 			if not host then
@@ -111,8 +112,8 @@
 		stanza.attr.from = from;
 	end
 
-	if (origin.type == "s2sin" or origin.type == "c2s" or origin.type == "component") and xmlns == nil then
-		if origin.type == "s2sin" and not origin.dummy then
+	if (origin.type == "s2sin" or origin.type == "s2sout" or origin.type == "c2s" or origin.type == "component") and xmlns == nil then
+		if (origin.type == "s2sin" or origin.type == "s2sout") and not origin.dummy then
 			local host_status = origin.hosts[from_host];
 			if not host_status or not host_status.authed then -- remote server trying to impersonate some other server?
 				log("warn", "Received a stanza claiming to be from %s, over a stream authed for %s!", from_host, origin.from_host);
@@ -171,8 +172,15 @@
 		end
 	end
 
-	local event_data = {origin=origin, stanza=stanza};
+	local event_data = {origin=origin, stanza=stanza, to_self=to_self};
+
 	if preevents then -- c2s connection
+		local result = hosts[origin.host].events.fire_event("pre-stanza", event_data);
+		if result ~= nil then
+			log("debug", "Stanza rejected by pre-stanza handler: %s", event_data.reason or "unknown reason");
+			return;
+		end
+
 		if hosts[origin.host].events.fire_event('pre-'..stanza.name..to_type, event_data) then return; end -- do preprocessing
 	end
 	local h = hosts[to_bare] or hosts[host or origin.host];
@@ -186,25 +194,25 @@
 end
 
 function core_route_stanza(origin, stanza)
-	local node, host, resource = jid_split(stanza.attr.to);
-	local from_node, from_host, from_resource = jid_split(stanza.attr.from);
+	local to_host = jid_host(stanza.attr.to);
+	local from_host = jid_host(stanza.attr.from);
 
 	-- Auto-detect origin if not specified
 	origin = origin or hosts[from_host];
 	if not origin then return false; end
 
-	if hosts[host] then
+	if hosts[to_host] then
 		-- old stanza routing code removed
 		core_post_stanza(origin, stanza);
 	else
 		local host_session = hosts[from_host];
 		if not host_session then
-			log("error", "No hosts[from_host] (please report): %s", tostring(stanza));
+			log("error", "No hosts[from_host] (please report): %s", stanza);
 		else
 			local xmlns = stanza.attr.xmlns;
 			stanza.attr.xmlns = nil;
 			local routed = host_session.events.fire_event("route/remote", {
-				origin = origin, stanza = stanza, from_host = from_host, to_host = host });
+				origin = origin, stanza = stanza, from_host = from_host, to_host = to_host });
 			stanza.attr.xmlns = xmlns; -- reset
 			if not routed then
 				log("debug", "Could not route stanza to remote");
--- a/core/statsmanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/statsmanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -3,10 +3,12 @@
 local log = require "util.logger".init("stats");
 local timer = require "util.timer";
 local fire_event = prosody.events.fire_event;
+local array = require "util.array";
+local timed = require "util.openmetrics".timed;
 
 local stats_interval_config = config.get("*", "statistics_interval");
 local stats_interval = tonumber(stats_interval_config);
-if stats_interval_config and not stats_interval then
+if stats_interval_config and not stats_interval and stats_interval_config ~= "manual" then
 	log("error", "Invalid 'statistics_interval' setting, statistics will be disabled");
 end
 
@@ -19,6 +21,9 @@
 elseif stats_provider and not stats_interval then
 	stats_interval = 60;
 end
+if stats_interval_config == "manual" then
+	stats_interval = nil;
+end
 
 local builtin_providers = {
 	internal = "util.statistics";
@@ -54,19 +59,152 @@
 	log("error", "Error loading statistics provider '%s': %s", stats_provider, stats_err);
 end
 
-local measure, collect;
-local latest_stats = {};
-local changed_stats = {};
-local stats_extra = {};
+local measure, collect, metric, cork, uncork;
 
 if stats then
-	function measure(type, name)
-		local f = assert(stats[type], "unknown stat type: "..type);
-		return f(name);
+	function metric(type_, name, unit, description, labels, extra)
+		local registry = stats.metric_registry
+		local f = assert(registry[type_], "unknown metric family type: "..type_);
+		return f(registry, name, unit or "", description or "", labels, extra);
+	end
+
+	local function new_legacy_metric(stat_type, name, unit, description, fixed_label_key, fixed_label_value, extra)
+		local label_keys = array()
+		local conf = extra or {}
+		if fixed_label_key then
+			label_keys:push(fixed_label_key)
+		end
+		unit = unit or ""
+		local mf = metric(stat_type, "prosody_" .. name, unit, description, label_keys, conf);
+		if fixed_label_key then
+			mf = mf:with_partial_label(fixed_label_value)
+		end
+		return mf:with_labels()
+	end
+
+	local function unwrap_legacy_extra(extra, type_, name, unit)
+		local description = extra and extra.description or name.." "..type_
+		unit = extra and extra.unit or unit
+		return description, unit
 	end
 
-	if stats_interval then
-		log("debug", "Statistics enabled using %s provider, collecting every %d seconds", stats_provider_name, stats_interval);
+	-- These wrappers provide the pre-OpenMetrics interface of statsmanager
+	-- and moduleapi (module:measure).
+	local legacy_metric_wrappers = {
+		amount = function(name, fixed_label_key, fixed_label_value, extra)
+			local initial = 0
+			if type(extra) == "number" then
+				initial = extra
+			else
+				initial = extra and extra.initial or initial
+			end
+			local description, unit = unwrap_legacy_extra(extra, "amount", name)
+
+			local m = new_legacy_metric("gauge", name, unit, description, fixed_label_key, fixed_label_value)
+			m:set(initial or 0)
+			return function(v)
+				m:set(v)
+			end
+		end;
+
+		counter = function(name, fixed_label_key, fixed_label_value, extra)
+			if type(extra) == "number" then
+				-- previous versions of the API allowed passing an initial
+				-- value here; we do not allow that anymore, it is not a thing
+				-- which makes sense with counters
+				extra = nil
+			end
+
+			local description, unit = unwrap_legacy_extra(extra, "counter", name)
+
+			local m = new_legacy_metric("counter", name, unit, description, fixed_label_key, fixed_label_value)
+			m:set(0)
+			return function(v)
+				m:add(v)
+			end
+		end;
+
+		rate = function(name, fixed_label_key, fixed_label_value, extra)
+			if type(extra) == "number" then
+				-- previous versions of the API allowed passing an initial
+				-- value here; we do not allow that anymore, it is not a thing
+				-- which makes sense with counters
+				extra = nil
+			end
+
+			local description, unit = unwrap_legacy_extra(extra, "counter", name)
+
+			local m = new_legacy_metric("counter", name, unit, description, fixed_label_key, fixed_label_value)
+			m:set(0)
+			return function()
+				m:add(1)
+			end
+		end;
+
+		times = function(name, fixed_label_key, fixed_label_value, extra)
+			local conf = {}
+			if extra and extra.buckets then
+				conf.buckets = extra.buckets
+			else
+				conf.buckets = { 0.001, 0.01, 0.1, 1.0, 10.0, 100.0 }
+			end
+			local description, _ = unwrap_legacy_extra(extra, "times", name)
+
+			local m = new_legacy_metric("histogram", name, "seconds", description, fixed_label_key, fixed_label_value, conf)
+			return function()
+				return timed(m)
+			end
+		end;
+
+		sizes = function(name, fixed_label_key, fixed_label_value, extra)
+			local conf = {}
+			if extra and extra.buckets then
+				conf.buckets = extra.buckets
+			else
+				conf.buckets = { 1024, 4096, 32768, 131072, 1048576, 4194304, 33554432, 134217728, 1073741824 }
+			end
+			local description, _ = unwrap_legacy_extra(extra, "sizes", name)
+
+			local m = new_legacy_metric("histogram", name, "bytes", description, fixed_label_key, fixed_label_value, conf)
+			return function(v)
+				m:sample(v)
+			end
+		end;
+
+		distribution = function(name, fixed_label_key, fixed_label_value, extra)
+			if type(extra) == "string" then
+				-- compat with previous API
+				extra = { unit = extra }
+			end
+			local description, unit = unwrap_legacy_extra(extra, "distribution", name, "")
+			local m = new_legacy_metric("summary", name, unit, description, fixed_label_key, fixed_label_value)
+			return function(v)
+				m:sample(v)
+			end
+		end;
+	};
+
+	-- Argument order switched here to support the legacy statsmanager.measure
+	-- interface.
+	function measure(stat_type, name, extra, fixed_label_key, fixed_label_value)
+		local wrapper = assert(legacy_metric_wrappers[stat_type], "unknown legacy metric type "..stat_type)
+		return wrapper(name, fixed_label_key, fixed_label_value, extra)
+	end
+
+	if stats.cork then
+		function cork()
+			return stats:cork()
+		end
+
+		function uncork()
+			return stats:uncork()
+		end
+	else
+		function cork() end
+		function uncork() end
+	end
+
+	if stats_interval or stats_interval_config == "manual" then
 
 		local mark_collection_start = measure("times", "stats.collection");
 		local mark_processing_start = measure("times", "stats.processing");
@@ -74,44 +212,68 @@
 		function collect()
 			local mark_collection_done = mark_collection_start();
 			fire_event("stats-update");
+			-- ensure that the backend is uncorked, in case it got stuck at
+			-- some point, to avoid infinite resource use
+			uncork()
 			mark_collection_done();
+			local manual_result = nil
 
-			if stats.get_stats then
-				changed_stats, stats_extra = {}, {};
-				for stat_name, getter in pairs(stats.get_stats()) do
-					local type, value, extra = getter();
-					local old_value = latest_stats[stat_name];
-					latest_stats[stat_name] = value;
-					if value ~= old_value then
-						changed_stats[stat_name] = value;
-					end
-					if extra then
-						stats_extra[stat_name] = extra;
-					end
-				end
+			if stats.metric_registry then
+				-- only if supported by the backend, we fire the event which
+				-- provides the current metric values
 				local mark_processing_done = mark_processing_start();
-				fire_event("stats-updated", { stats = latest_stats, changed_stats = changed_stats, stats_extra = stats_extra });
+				local metric_registry = stats.metric_registry;
+				fire_event("openmetrics-updated", { metric_registry = metric_registry })
 				mark_processing_done();
+				manual_result = metric_registry;
 			end
-			return stats_interval;
+
+			return stats_interval, manual_result;
 		end
-		timer.add_task(stats_interval, collect);
-		prosody.events.add_handler("server-started", function () collect() end, -1);
+		if stats_interval then
+			log("debug", "Statistics enabled using %s provider, collecting every %d seconds", stats_provider_name, stats_interval);
+			timer.add_task(stats_interval, collect);
+			prosody.events.add_handler("server-started", function () collect() end, -1);
+			prosody.events.add_handler("server-stopped", function () collect() end, -1);
+		else
+			log("debug", "Statistics enabled using %s provider, no scheduled collection", stats_provider_name);
+		end
 	else
 		log("debug", "Statistics enabled using %s provider, collection is disabled", stats_provider_name);
 	end
 else
 	log("debug", "Statistics disabled");
 	function measure() return measure; end
+
+	local dummy_mt = {}
+	function dummy_mt.__newindex()
+	end
+	function dummy_mt:__index()
+		return self
+	end
+	function dummy_mt:__call()
+		return self
+	end
+	local dummy = {}
+	setmetatable(dummy, dummy_mt)
+
+	function metric() return dummy; end
+	function cork() end
+	function uncork() end
 end
 
+local exported_collect = nil;
+if stats_interval_config == "manual" then
+	exported_collect = collect;
+end
 
 return {
+	collect = exported_collect;
 	measure = measure;
-	get_stats = function ()
-		return latest_stats, changed_stats, stats_extra;
-	end;
-	get = function (name)
-		return latest_stats[name], stats_extra[name];
+	cork = cork;
+	uncork = uncork;
+	metric = metric;
+	get_metric_registry = function ()
+		return stats and stats.metric_registry or nil
 	end;
 };
--- a/core/storagemanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/storagemanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -167,6 +167,39 @@
 			return self.keyval_store:set(username, current);
 		end;
 		remove = {};
+		get_all = function (self, key)
+			if type(key) ~= "string" or key == "" then
+				return nil, "get_all only supports non-empty string keys";
+			end
+			local ret;
+			for username in self.keyval_store:users() do
+				local key_data = self:get(username, key);
+				if key_data then
+					if not ret then
+						ret = {};
+					end
+					ret[username] = key_data;
+				end
+			end
+			return ret;
+		end;
+		delete_all = function (self, key)
+			if type(key) ~= "string" or key == "" then
+				return nil, "delete_all only supports non-empty string keys";
+			end
+			local data = { [key] = self.remove };
+			local last_err;
+			for username in self.keyval_store:users() do
+				local ok, err = self:set_keys(username, data);
+				if not ok then
+					last_err = err;
+				end
+			end
+			if last_err then
+				return nil, last_err;
+			end
+			return true;
+		end;
 	};
 }
 
--- a/core/usermanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/core/usermanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,19 +9,21 @@
 local modulemanager = require "core.modulemanager";
 local log = require "util.logger".init("usermanager");
 local type = type;
-local ipairs = ipairs;
+local it = require "util.iterators";
 local jid_bare = require "util.jid".bare;
+local jid_split = require "util.jid".split;
 local jid_prep = require "util.jid".prep;
 local config = require "core.configmanager";
 local sasl_new = require "util.sasl".new;
 local storagemanager = require "core.storagemanager";
+local set = require "util.set";
 
 local prosody = _G.prosody;
 local hosts = prosody.hosts;
 
 local setmetatable = setmetatable;
 
-local default_provider = "internal_plain";
+local default_provider = "internal_hashed";
 
 local _ENV = nil;
 -- luacheck: std none
@@ -34,10 +36,38 @@
 	});
 end
 
+local global_admins_config = config.get("*", "admins");
+if type(global_admins_config) ~= "table" then
+	global_admins_config = nil; -- TODO: factor out moduleapi magic config handling and use it here
+end
+local global_admins = set.new(global_admins_config) / jid_prep;
+
+local admin_role = { ["prosody:admin"] = true };
+local global_authz_provider = {
+	get_user_roles = function (user) end; --luacheck: ignore 212/user
+	get_jid_roles = function (jid)
+		if global_admins:contains(jid) then
+			return admin_role;
+		end
+	end;
+	get_jids_with_role = function (role)
+		if role ~= "prosody:admin" then return {}; end
+		return it.to_array(global_admins);
+	end;
+	set_user_roles = function (user, roles) end; -- luacheck: ignore 212
+	set_jid_roles = function (jid, roles) end; -- luacheck: ignore 212
+};
+
 local provider_mt = { __index = new_null_provider() };
 
 local function initialize_host(host)
 	local host_session = hosts[host];
+
+	local authz_provider_name = config.get(host, "authorization") or "internal";
+
+	local authz_mod = modulemanager.load(host, "authz_"..authz_provider_name);
+	host_session.authz = authz_mod or global_authz_provider;
+
 	if host_session.type ~= "local" then return; end
 
 	host_session.events.add_handler("item-added/auth-provider", function (event)
@@ -66,6 +96,7 @@
 	if auth_provider ~= "null" then
 		modulemanager.load(host, "auth_"..auth_provider);
 	end
+
 end;
 prosody.events.add_handler("host-activated", initialize_host, 100);
 
@@ -113,45 +144,70 @@
 	return hosts[host].users;
 end
 
-local function is_admin(jid, host)
+local function get_roles(jid, host)
+	if host and not hosts[host] then return false; end
+	if type(jid) ~= "string" then return false; end
+
+	jid = jid_bare(jid);
+	host = host or "*";
+
+	local actor_user, actor_host = jid_split(jid);
+	local roles;
+
+	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
+
+	if actor_user and actor_host == host then -- Local user
+		roles = authz_provider.get_user_roles(actor_user);
+	else -- Remote user/JID
+		roles = authz_provider.get_jid_roles(jid);
+	end
+
+	return roles;
+end
+
+local function set_roles(jid, host, roles)
 	if host and not hosts[host] then return false; end
 	if type(jid) ~= "string" then return false; end
 
 	jid = jid_bare(jid);
 	host = host or "*";
 
-	local host_admins = config.get(host, "admins");
-	local global_admins = config.get("*", "admins");
+	local actor_user, actor_host = jid_split(jid);
 
-	if host_admins and host_admins ~= global_admins then
-		if type(host_admins) == "table" then
-			for _,admin in ipairs(host_admins) do
-				if jid_prep(admin) == jid then
-					return true;
-				end
-			end
-		elseif host_admins then
-			log("error", "Option 'admins' for host '%s' is not a list", host);
+	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
+	if actor_user and actor_host == host then -- Local user
+		local ok, err = authz_provider.set_user_roles(actor_user, roles);
+		if ok then
+			prosody.events.fire_event("user-roles-changed", {
+				username = actor_user, host = actor_host
+			});
 		end
+		return ok, err;
+	else -- Remote entity
+		return authz_provider.set_jid_roles(jid, roles)
 	end
+end
 
-	if global_admins then
-		if type(global_admins) == "table" then
-			for _,admin in ipairs(global_admins) do
-				if jid_prep(admin) == jid then
-					return true;
-				end
-			end
-		elseif global_admins then
-			log("error", "Global option 'admins' is not a list");
-		end
-	end
+local function is_admin(jid, host)
+	local roles = get_roles(jid, host);
+	return roles and roles["prosody:admin"];
+end
+
+local function get_users_with_role(role, host)
+	if not hosts[host] then return false; end
+	if type(role) ~= "string" then return false; end
 
-	-- Still not an admin, check with auth provider
-	if host ~= "*" and hosts[host].users and hosts[host].users.is_admin then
-		return hosts[host].users.is_admin(jid);
-	end
-	return false;
+	return hosts[host].authz.get_users_with_role(role);
+end
+
+local function get_jids_with_role(role, host)
+	if host and not hosts[host] then return false; end
+	if type(role) ~= "string" then return false; end
+
+	host = host or "*";
+
+	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
+	return authz_provider.get_jids_with_role(role);
 end
 
 return {
@@ -166,5 +222,9 @@
 	users = users;
 	get_sasl_handler = get_sasl_handler;
 	get_provider = get_provider;
+	get_roles = get_roles;
+	set_roles = set_roles;
 	is_admin = is_admin;
+	get_users_with_role = get_users_with_role;
+	get_jids_with_role = get_jids_with_role;
 };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/coding_style.md	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,804 @@
+
+# Prosody Coding Style Guide
+
+This style guides lists the coding conventions used in the
+[Prosody](https://prosody.im/) project. It is based heavily on the [style guide used by the LuaRocks project](https://github.com/luarocks/lua-style-guide).
+
+## Indentation and formatting
+
+* Prosody code is indented with tabs at the start of the line, a single
+  tab per logical indent level:
+
+```lua
+for i, pkg in ipairs(packages) do
+    for name, version in pairs(pkg) do
+        if name == searched then
+            print(version);
+        end
+    end
+end
+```
+
+Tab width is configurable in editors, so never assume a particular width.
+Specifically this means you should not mix tabs and spaces, or use tabs for
+alignment of items at different indentation levels.
+
+* Use LF (Unix) line endings.
+
+## Comments
+
+* Comments are encouraged where necessary to explain non-obvious code.
+
+* In general comments should be used to explain 'why', not 'how'
+
+### Comment tags
+
+A comment may be prefixed with one of the following tags:
+
+* **FIXME**: Indicates a serious problem with the code that should be addressed
+* **TODO**: Indicates an open task, feature request or code restructuring that
+  is primarily of interest to developers (otherwise it should be in the
+  issue tracker).
+* **COMPAT**: Must be used on all code that is present only for backwards-compatibility,
+  and may be removed one day. For example code that is added to support old
+  or buggy third-party software or dependencies.
+
+**Example:**
+
+```lua
+-- TODO: implement method
+local function something()
+   -- FIXME: check conditions
+end
+
+```
+
+## Variable names
+
+* Variable names with larger scope should be more descriptive than those with
+smaller scope. One-letter variable names should be avoided except for very
+small scopes (less than ten lines) or for iterators.
+
+* `i` should be used only as a counter variable in for loops (either numeric for
+or `ipairs`).
+
+* Prefer more descriptive names than `k` and `v` when iterating with `pairs`,
+unless you are writing a function that operates on generic tables.
+
+* Use `_` for ignored variables (e.g. in for loops:)
+
+```lua
+for _, item in ipairs(items) do
+   do_something_with_item(item);
+end
+```
+
+* Generally all identifiers (variables and function names) should use `snake_case`,
+  i.e. lowercase words joined by `_`.
+
+```lua
+-- bad
+local OBJEcttsssss = {}
+local thisIsMyObject = {}
+local c = function()
+   -- ...stuff...
+end
+
+-- good
+local this_is_my_object = {};
+
+local function do_that_thing()
+   -- ...stuff...
+end
+```
+
+> **Rationale:** The standard library uses lowercase APIs, with `joinedlowercase`
+names, but this does not scale too well for more complex APIs. `snake_case`
+tends to look good enough and not too out-of-place along side the standard
+APIs.
+
+```lua
+for _, name in pairs(names) do
+   -- ...stuff...
+end
+```
+
+* Prefer using `is_` when naming boolean functions:
+
+```lua
+-- bad
+local function evil(alignment)
+   return alignment < 100
+end
+
+-- good
+local function is_evil(alignment)
+   return alignment < 100;
+end
+```
+
+* `UPPER_CASE` is to be used sparingly, with "constants" only.
+
+> **Rationale:** "Sparingly", since Lua does not have real constants. This
+notation is most useful in libraries that bind C libraries, when bringing over
+constants from C.
+
+* Do not use uppercase names starting with `_`, they are reserved by Lua.
+
+## Tables
+
+* When creating a table, prefer populating its fields all at once, if possible:
+
+```lua
+local player = { name = "Jack", class = "Rogue" };
+```
+
+* Items should be separated by commas. If there are many items, put each
+  key/value on a separate line and use a semi-colon after each item (including
+  the last one):
+
+```lua
+local player = {
+   name = "Jack";
+   class = "Rogue";
+}
+```
+
+> **Rationale:** This makes the structure of your tables more evident at a glance.
+Trailing semi-colons make it quicker to add new fields and produces shorter diffs.
+
+* Use plain `key` syntax whenever possible, use `["key"]` syntax when using names
+that can't be represented as identifiers and avoid mixing representations in
+a declaration:
+
+```lua
+local mytable = {
+   ["1394-E"] = val1;
+   ["UTF-8"] = val2;
+   ["and"] = val2;
+}
+```
+
+## Strings
+
+* Use `"double quotes"` for strings; use `'single quotes'` when writing strings
+that contain double quotes.
+
+```lua
+local name = "Prosody";
+local sentence = 'The name of the program is "Prosody"';
+```
+
+> **Rationale:** Double quotes are used as string delimiters in a larger number of
+programming languages. Single quotes are useful for avoiding escaping when
+using double quotes in literals.
+
+## Line lengths
+
+* There are no hard or soft limits on line lengths. Line lengths are naturally
+limited by using one statement per line. If that still produces lines that are
+too long (e.g. an expression that produces a line over 256-characters long,
+for example), this means the expression is too complex and would do better
+split into subexpressions with reasonable names.
+
+> **Rationale:** No one works on VT100 terminals anymore. If line lengths are a proxy
+for code complexity, we should address code complexity instead of using line
+breaks to fit mind-bending statements over multiple lines.
+
+## Function declaration syntax
+
+* Prefer function syntax over variable syntax. This helps differentiate between
+named and anonymous functions.
+
+```lua
+-- bad
+local nope = function(name, options)
+   -- ...stuff...
+end
+
+-- good
+local function yup(name, options)
+   -- ...stuff...
+end
+```
+
+* Perform validation early and return as early as possible.
+
+```lua
+-- bad
+local function is_good_name(name, options, arg)
+   local is_good = #name > 3
+   is_good = is_good and #name < 30
+
+   -- ...stuff...
+
+   return is_good
+end
+
+-- good
+local function is_good_name(name, options, args)
+   if #name < 3 or #name > 30 then
+      return false;
+   end
+
+   -- ...stuff...
+
+   return true;
+end
+```
+
+## Function calls
+
+* Even though Lua allows it, generally you should not omit parentheses
+  for functions that take a unique string literal argument.
+
+```lua
+-- bad
+local data = get_data"KRP"..tostring(area_number)
+-- good
+local data = get_data("KRP"..tostring(area_number));
+local data = get_data("KRP")..tostring(area_number);
+```
+
+> **Rationale:** It is not obvious at a glace what the precedence rules are
+when omitting the parentheses in a function call. Can you quickly tell which
+of the two "good" examples in equivalent to the "bad" one? (It's the second
+one).
+
+* You should not omit parenthesis for functions that take a unique table
+argument on a single line. You may do so for table arguments that span several
+lines.
+
+```lua
+local an_instance = a_module.new {
+   a_parameter = 42;
+   another_parameter = "yay";
+}
+```
+
+> **Rationale:** The use as in `a_module.new` above occurs alone in a statement,
+so there are no precedence issues.
+
+## Table attributes
+
+* Use dot notation when accessing known properties.
+
+```lua
+local luke = {
+   jedi = true;
+   age = 28;
+}
+
+-- bad
+local is_jedi = luke["jedi"]
+
+-- good
+local is_jedi = luke.jedi;
+```
+
+* Use subscript notation `[]` when accessing properties with a variable or if using a table as a list.
+
+```lua
+local vehicles = load_vehicles_from_disk("vehicles.dat")
+
+if vehicles["Porsche"] then
+   porsche_handler(vehicles["Porsche"]);
+   vehicles["Porsche"] = nil;
+end
+for name, cars in pairs(vehicles) do
+   regular_handler(cars);
+end
+```
+
+> **Rationale:** Using dot notation makes it clearer that the given key is meant
+to be used as a record/object field.
+
+## Functions in tables
+
+* When declaring modules and classes, declare functions external to the table definition:
+
+```lua
+local my_module = {};
+
+function my_module.a_function(x)
+   -- code
+end
+```
+
+* When declaring metatables, declare function internal to the table definition.
+
+```lua
+local version_mt = {
+   __eq = function(a, b)
+      -- code
+   end;
+   __lt = function(a, b)
+      -- code
+   end;
+}
+```
+
+> **Rationale:** Metatables contain special behavior that affect the tables
+they're assigned (and are used implicitly at the call site), so it's good to
+be able to get a view of the complete behavior of the metatable at a glance.
+
+This is not as important for objects and modules, which usually have way more
+code, and which don't fit in a single screen anyway, so nesting them inside
+the table does not gain much: when scrolling a longer file, it is more evident
+that `check_version` is a method of `Api` if it says `function Api:check_version()`
+than if it says `check_version = function()` under some indentation level.
+
+## Variable declaration
+
+* Always use `local` to declare variables.
+
+```lua
+-- bad
+superpower = get_superpower()
+
+-- good
+local superpower = get_superpower();
+```
+
+> **Rationale:** Not doing so will result in global variables to avoid polluting
+the global namespace.
+
+## Variable scope
+
+* Assign variables with the smallest possible scope.
+
+```lua
+-- bad
+local function good()
+   local name = get_name()
+
+   test()
+   print("doing stuff..")
+
+   --...other stuff...
+
+   if name == "test" then
+      return false
+   end
+
+   return name
+end
+
+-- good
+local bad = function()
+   test();
+   print("doing stuff..");
+
+   --...other stuff...
+
+   local name = get_name();
+
+   if name == "test" then
+      return false;
+   end
+
+   return name;
+end
+```
+
+> **Rationale:** Lua has proper lexical scoping. Declaring the function later means that its
+scope is smaller, so this makes it easier to check for the effects of a variable.
+
+## Conditional expressions
+
+* False and nil are falsy in conditional expressions. Use shortcuts when you
+can, unless you need to know the difference between false and nil.
+
+```lua
+-- bad
+if name ~= nil then
+   -- ...stuff...
+end
+
+-- good
+if name then
+   -- ...stuff...
+end
+```
+
+* Avoid designing APIs which depend on the difference between `nil` and `false`.
+
+* Use the `and`/`or` idiom for the pseudo-ternary operator when it results in
+more straightforward code. When nesting expressions, use parentheses to make it
+easier to scan visually:
+
+```lua
+local function default_name(name)
+   -- return the default "Waldo" if name is nil
+   return name or "Waldo";
+end
+
+local function brew_coffee(machine)
+   return (machine and machine.is_loaded) and "coffee brewing" or "fill your water";
+end
+```
+
+Note that the `x and y or z` as a substitute for `x ? y : z` does not work if
+`y` may be `nil` or `false` so avoid it altogether for returning booleans or
+values which may be nil.
+
+## Blocks
+
+* Use single-line blocks only for `then return`, `then break` and `function return` (a.k.a "lambda") constructs:
+
+```lua
+-- good
+if test then break end
+
+-- good
+if not ok then return nil, "this failed for this reason: " .. reason end
+
+-- good
+use_callback(x, function(k) return k.last end);
+
+-- good
+if test then
+  return false
+end
+
+-- bad
+if test < 1 and do_complicated_function(test) == false or seven == 8 and nine == 10 then do_other_complicated_function() end
+
+-- good
+if test < 1 and do_complicated_function(test) == false or seven == 8 and nine == 10 then
+   do_other_complicated_function();
+   return false;
+end
+```
+
+* Separate statements onto multiple lines. Use semicolons as statement terminators.
+
+```lua
+-- bad
+local whatever = "sure"
+a = 1 b = 2
+
+-- good
+local whatever = "sure";
+a = 1;
+b = 2;
+```
+
+## Spacing
+
+* Use a space after `--`.
+
+```lua
+--bad
+-- good
+```
+
+* Always put a space after commas and between operators and assignment signs:
+
+```lua
+-- bad
+local x = y*9
+local numbers={1,2,3}
+numbers={1 , 2 , 3}
+numbers={1 ,2 ,3}
+local strings = { "hello"
+                , "Lua"
+                , "world"
+                }
+dog.set( "attr",{
+  age="1 year",
+  breed="Bernese Mountain Dog"
+})
+
+-- good
+local x = y * 9;
+local numbers = {1, 2, 3};
+local strings = {
+    "hello";
+    "Lua";
+    "world";
+}
+dog.set("attr", {
+   age = "1 year";
+   breed = "Bernese Mountain Dog";
+});
+```
+
+* Indent tables and functions according to the start of the line, not the construct:
+
+```lua
+-- bad
+local my_table = {
+                    "hello",
+                    "world",
+                 }
+using_a_callback(x, function(...)
+                       print("hello")
+                    end)
+
+-- good
+local my_table = {
+    "hello";
+    "world";
+}
+using_a_callback(x, function(...)
+   print("hello");
+end)
+```
+
+> **Rationale:** This keep indentation levels aligned at predictable places. You don't
+need to realign the entire block if something in the first line changes (such as
+replacing `x` with `xy` in the `using_a_callback` example above).
+
+* The concatenation operator gets a pass for avoiding spaces:
+
+```lua
+-- okay
+local message = "Hello, "..user.."! This is your day # "..day.." in our platform!";
+```
+
+> **Rationale:** Being at the baseline, the dots already provide some visual spacing.
+
+* No spaces after the name of a function in a declaration or in its arguments:
+
+```lua
+-- bad
+local function hello ( name, language )
+   -- code
+end
+
+-- good
+local function hello(name, language)
+   -- code
+end
+```
+
+* Add blank lines between functions:
+
+```lua
+-- bad
+local function foo()
+   -- code
+end
+local function bar()
+   -- code
+end
+
+-- good
+local function foo()
+   -- code
+end
+
+local function bar()
+   -- code
+end
+```
+
+* Avoid aligning variable declarations:
+
+```lua
+-- bad
+local a               = 1
+local long_identifier = 2
+
+-- good
+local a = 1;
+local long_identifier = 2;
+```
+
+> **Rationale:** This produces extra diffs which add noise to `hg annotate`.
+
+* Alignment is occasionally useful when logical correspondence is to be highlighted:
+
+```lua
+-- okay
+sys_command(form, UI_FORM_UPDATE_NODE, "a",      FORM_NODE_HIDDEN,  false);
+sys_command(form, UI_FORM_UPDATE_NODE, "sample", FORM_NODE_VISIBLE, false);
+```
+
+## Typing
+
+* In non-performance critical code, it can be useful to add type-checking assertions
+for function arguments:
+
+```lua
+function manif.load_manifest(repo_url, lua_version)
+   assert(type(repo_url) == "string");
+   assert(type(lua_version) == "string" or not lua_version);
+
+   -- ...
+end
+```
+
+* Use the standard functions for type conversion, avoid relying on coercion:
+
+```lua
+-- bad
+local total_score = review_score .. ""
+
+-- good
+local total_score = tostring(review_score);
+```
+
+## Errors
+
+* Functions that can fail for reasons that are expected (e.g. I/O) should
+return `nil` and a (string) error message on error, possibly followed by other
+return values such as an error code.
+
+* On errors such as API misuse, an error should be thrown, either with `error()`
+or `assert()`.
+
+## Modules
+
+Follow [these guidelines](http://hisham.hm/2014/01/02/how-to-write-lua-modules-in-a-post-module-world/) for writing modules. In short:
+
+* Always require a module into a local variable named after the last component of the module’s full name.
+
+```lua
+local bar = require("foo.bar"); -- requiring the module
+
+bar.say("hello"); -- using the module
+```
+
+* Don’t rename modules arbitrarily:
+
+```lua
+-- bad
+local skt = require("socket")
+```
+
+> **Rationale:** Code is much harder to read if we have to keep going back to the top
+to check how you chose to call a module.
+
+* Start a module by declaring its table using the same all-lowercase local
+name that will be used to require it. You may use an LDoc comment to identify
+the whole module path.
+
+```lua
+--- @module foo.bar
+local bar = {};
+```
+
+* Try to use names that won't clash with your local variables. For instance, don't
+name your module something like “size”.
+
+* Use `local function` to declare _local_ functions only: that is, functions
+that won’t be accessible from outside the module.
+
+That is, `local function helper_foo()` means that `helper_foo` is really local.
+
+* Public functions are declared in the module table, with dot syntax:
+
+```lua
+function bar.say(greeting)
+   print(greeting);
+end
+```
+
+> **Rationale:** Visibility rules are made explicit through syntax.
+
+* Do not set any globals in your module and always return a table in the end.
+
+* If you would like your module to be used as a function, you may set the
+`__call` metamethod on the module table instead.
+
+> **Rationale:** Modules should return tables in order to be amenable to have their
+contents inspected via the Lua interactive interpreter or other tools.
+
+* Requiring a module should cause no side-effect other than loading other
+modules and returning the module table.
+
+* A module should not have state. If a module needs configuration, turn
+  it into a factory. For example, do not make something like this:
+
+```lua
+-- bad
+local mp = require "MessagePack"
+mp.set_integer("unsigned")
+```
+
+and do something like this instead:
+
+```lua
+-- good
+local messagepack = require("messagepack");
+local mpack = messagepack.new({integer = "unsigned"});
+```
+
+* The invocation of require may omit parentheses around the module name:
+
+```lua
+local bla = require "bla";
+```
+
+## Metatables, classes and objects
+
+If creating a new type of object that has a metatable and methods, the
+metatable and methods table should be separate, and the metatable name
+should end with `_mt`.
+
+```lua
+local mytype_methods = {};
+local mytype_mt = { __index = mytype_methods };
+
+function mytype_methods:add_new_thing(thing)
+end
+
+local function new()
+    return setmetatable({}, mytype_mt);
+end
+
+return { new = new };
+```
+
+* Use the method notation when invoking methods:
+
+```
+-- bad
+my_object.my_method(my_object)
+
+-- good
+my_object:my_method();
+```
+
+> **Rationale:** This makes it explicit that the intent is to use the function as a method.
+
+* Do not rely on the `__gc` metamethod to release resources other than memory.
+If your object manage resources such as files, add a `close` method to their
+APIs and do not auto-close via `__gc`. Auto-closing via `__gc` would entice
+users of your module to not close resources as soon as possible. (Note that
+the standard `io` library does not follow this recommendation, and users often
+forget that not closing files immediately can lead to "too many open files"
+errors when the program runs for a while.)
+
+> **Rationale:** The garbage collector performs automatic *memory* management,
+dealing with memory only. There is no guarantees as to when the garbage
+collector will be invoked, and memory pressure does not correlate to pressure
+on other resources.
+
+## File structure
+
+* Lua files should be named in all lowercase.
+
+* Tests should be in a top-level `spec` directory. Prosody uses
+[Busted](http://olivinelabs.com/busted/) for testing.
+
+## Static checking
+
+All code should pass [luacheck](https://github.com/mpeterv/luacheck) using
+the `.luacheckrc` provided in the Prosody repository, and using minimal
+inline exceptions.
+
+* luacheck warnings of class 211, 212, 213 (unused variable, argument or loop
+variable) may be ignored, if the unused variable was added explicitly: for
+example, sometimes it is useful, for code understandability, to spell out what
+the keys and values in a table are, even if you're only using one of them.
+Another example is a function that needs to follow a given signature for API
+reasons (e.g. a callback that follows a given format) but doesn't use some of
+its arguments; it's better to spell out in the argument what the API the
+function implements is, instead of adding `_` variables.
+
+```
+local foo, bar = some_function(); --luacheck: ignore 212/foo
+print(bar);
+```
+
+* luacheck warning 542 (empty if branch) can also be ignored, when a sequence
+of `if`/`elseif`/`else` blocks implements a "switch/case"-style list of cases,
+and one of the cases is meant to mean "pass". For example:
+
+```lua
+if warning >= 600 and warning <= 699 then
+   print("no whitespace warnings");
+elseif warning == 542 then --luacheck: ignore 542
+   -- pass
+else
+   print("got a warning: "..warning);
+end
+```
+
+> **Rationale:** This avoids writing negated conditions in the final fallback
+case, and it's easy to add another case to the construct without having to
+edit the fallback.
+
--- a/doc/coding_style.txt	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-This file describes some coding styles to try and adhere to when contributing to this project.
-Please try to follow, and feel free to fix code you see not following this standard.
-
-== Indentation ==
-
-	1 tab indentation for all blocks
-
-== Spacing ==
-
-No space between function names and parenthesis and parenthesis and parameters:
-
-		function foo(bar, baz)
-
-Single space between braces and key/value pairs in table constructors:
-
-		{ foo = "bar", bar = "foo" }
-
-== Local variable naming ==
-
-In this project there are many places where use of globals is restricted, and locals used for faster access.
-
-Local versions of standard functions should follow the below form:
-
-	math.random -> m_random
-	string.char -> s_char	
-
-== Miscellaneous ==
-
-Single-statement blocks may be written on one line when short
-	
-	if foo then bar(); end
-
-'do' and 'then' keywords should be placed at the end of the line, and never on a line by themself.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/doap.xml	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,849 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<rdf:RDF xmlns:foaf="http://xmlns.com/foaf/0.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:schema="https://schema.org/" xmlns:xmpp="https://linkmauve.fr/ns/xmpp-doap#" xml:lang="en">
+  <Project xmlns="http://usefulinc.com/ns/doap#">
+    <name>Prosody IM</name>
+    <shortdesc>Lightweight XMPP server</shortdesc>
+    <description>Prosody is a server for Jabber/XMPP written in Lua. It aims to be easy to use and light on resources. For developers, it aims to give a flexible system on which to rapidly develop added functionality or rapidly prototype new protocols.</description>
+    <created>2008-08-22</created>
+    <category rdf:resource="https://linkmauve.fr/ns/xmpp-doap#category-xmpp"/>
+    <category rdf:resource="https://linkmauve.fr/ns/xmpp-doap#category-jabber"/>
+    <category rdf:resource="https://linkmauve.fr/ns/xmpp-doap#category-server"/>
+    <homepage rdf:resource="https://prosody.im/"/>
+    <download-page rdf:resource="https://prosody.im/download/"/>
+    <license rdf:resource="https://hg.prosody.im/trunk/file/tip/COPYING"/>
+    <schema:logo rdf:resource="https://prosody.im/prosody.svg"/>
+    <bug-database rdf:resource="https://issues.prosody.im/"/>
+    <support-forum rdf:resource="xmpp:prosody@conference.prosody.im?join"/>
+    <repository>
+      <HgRepository>
+        <location rdf:resource="https://hg.prosody.im/trunk/"/>
+        <browse rdf:resource="https://hg.prosody.im/trunk/"/>
+      </HgRepository>
+    </repository>
+    <programming-langauge>Lua</programming-langauge>
+    <programming-langauge>C</programming-langauge>
+    <os>Linux</os>
+    <os>macOS</os>
+    <os>FreeBSD</os>
+    <os>OpenBSD</os>
+    <os>NetBSD</os>
+    <maintainer>
+      <foaf:Person>
+        <foaf:name>Matthew Wild</foaf:name>
+        <foaf:nick>MattJ</foaf:nick>
+        <foaf:homepage>https://matthewwild.co.uk/</foaf:homepage>
+      </foaf:Person>
+    </maintainer>
+    <maintainer>
+      <foaf:Person>
+        <foaf:name>Waqas Hussain</foaf:name>
+        <foaf:nick>waqas</foaf:nick>
+      </foaf:Person>
+    </maintainer>
+    <maintainer>
+      <foaf:Person>
+        <foaf:name>Kim Alvefur</foaf:name>
+        <foaf:nick>Zash</foaf:nick>
+        <foaf:homepage>https://www.zash.se/</foaf:homepage>
+      </foaf:Person>
+    </maintainer>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc4627"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc5802"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc6120"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc6121"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc6122"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc6331"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc6455"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc6901"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7233"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7301"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7395"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7590"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7673"/>
+    <implements rdf:resource="https://datatracker.ietf.org/doc/draft-cridland-xmpp-session/">
+      <!-- since=0.6.0 note=Added in hg:0bbbc9042361 -->
+    </implements>
+    <implements rdf:resource="http://www.unicode.org/reports/tr39/"/>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0004.html"/>
+        <xmpp:version>2.12.1</xmpp:version>
+        <xmpp:since>0.4.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:note>no support for multiple items (reported tag)</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0009.html"/>
+        <xmpp:since>0.4.0</xmpp:since>
+        <xmpp:until>0.7.0</xmpp:until>
+        <xmpp:status>removed</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0012.html"/>
+        <xmpp:version>2.0</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:note>mod_lastactivity and mod_uptime</xmpp:note>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0016.html"/>
+        <xmpp:since>0.7.0</xmpp:since>
+        <xmpp:until>0.10.0</xmpp:until>
+        <xmpp:status>removed</xmpp:status>
+        <xmpp:note>mod_privacy</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0030.html"/>
+        <xmpp:version>2.5rc3</xmpp:version>
+        <xmpp:since>0.10.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0044.html"/>
+        <xmpp:version>0.1</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>libexpat</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0045.html"/>
+        <xmpp:version>1.34.1</xmpp:version>
+        <xmpp:since>0.3.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0048.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_private, indirectly supported via XEP-0049</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0049.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_private</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0050.html"/>
+        <xmpp:version>1.3.0</xmpp:version>
+        <xmpp:since>0.8.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_adhoc</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0054.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_vcard and mod_vcard_legacy</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0059.html"/>
+        <xmpp:version>1.0</xmpp:version>
+        <xmpp:since>0.10.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>used by XEP-0313, util.rsm</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0060.html"/>
+        <xmpp:version>1.22.0</xmpp:version>
+        <xmpp:since>0.9.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:note>mod_pubsub</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0065.html"/>
+        <xmpp:version>1.8.2</xmpp:version>
+        <xmpp:since>0.7.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_proxy65</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0068.html"/>
+        <xmpp:version>1.3.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0077.html"/>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:version>2.4</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0078.html"/>
+        <xmpp:version>2.5</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:note>mod_legacyauth, lacks digest method</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0080.html"/>
+        <xmpp:version>1.9</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0082.html"/>
+        <xmpp:version>1.1.1</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0084.html"/>
+        <xmpp:version>1.1.4</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163, also mod_vcard_legacy</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0090.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_time</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0091.html"/>
+        <xmpp:version>1.4</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:until>0.12.0</xmpp:until>
+        <xmpp:status>removed</xmpp:status>
+        <xmpp:note>Gone from offline messages in 0.10.0, gone from MUC in 0.12</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0092.html"/>
+        <xmpp:version>1.1</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0106.html"/>
+        <xmpp:version>1.0</xmpp:version>
+        <xmpp:since>0.9.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0107.html"/>
+        <xmpp:version>1.2.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0108.html"/>
+        <xmpp:version>1.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0114.html"/>
+        <xmpp:version>1.6</xmpp:version>
+        <xmpp:since>0.4.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0115.html"/>
+        <xmpp:version>1.5.2</xmpp:version>
+        <xmpp:since>0.8.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0118.html"/>
+        <xmpp:version>1.3.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0122.html"/>
+        <xmpp:version>1.0.2</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0124.html"/>
+        <xmpp:version>1.11.2</xmpp:version>
+        <xmpp:since>0.2.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_bosh</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0126.html"/>
+        <xmpp:until>0.10.0</xmpp:until>
+        <xmpp:status>removed</xmpp:status>
+        <xmpp:note>Gone with XEP-0016</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0128.html"/>
+        <xmpp:version>1.0.1</xmpp:version>
+        <xmpp:since>0.9.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0133.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.7.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:note>mod_admin_adhoc, missing some commands</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0138.html"/>
+        <xmpp:version>2.0</xmpp:version>
+        <xmpp:since>0.6.0</xmpp:since>
+        <xmpp:until>0.10.0</xmpp:until>
+        <xmpp:status>removed</xmpp:status>
+        <xmpp:note>Compression considered insecure</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0153.html"/>
+        <xmpp:version>1.1</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0398</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0157.html"/>
+        <xmpp:version>1.1.1</xmpp:version>
+        <xmpp:since>0.10.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0160.html"/>
+        <xmpp:version>1.0.1</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0163.html"/>
+        <xmpp:version>1.2.1</xmpp:version>
+        <xmpp:since>0.5.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0170.html"/>
+        <xmpp:version>1.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0172.html"/>
+        <xmpp:version>1.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163, also mod_vcard_legacy</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0175.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.4.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0178.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.9.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0182.html"/>
+        <xmpp:version>1.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0184.html"/>
+        <xmpp:version>1.4.0</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:note>mod_mam archives receipts</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0185.html"/>
+        <xmpp:version>1.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.9.10</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0189.html"/>
+        <xmpp:version>0.14</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0191.html"/>
+        <xmpp:version>1.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.10.0</xmpp:since>
+        <xmpp:note>mod_blocklist</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0194.html"/>
+        <xmpp:version>0.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0195.html"/>
+        <xmpp:version>0.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0196.html"/>
+        <xmpp:version>0.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0197.html"/>
+        <xmpp:version>0.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0198.html"/>
+        <xmpp:version>1.6</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:note>mod_smacks</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0199.html"/>
+        <xmpp:version>2.0.1</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0202.html"/>
+        <xmpp:version>2.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:note>mod_time</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0203.html"/>
+        <xmpp:version>2.0</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0205.html"/>
+        <xmpp:version>1.0.2</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>stanza size limits, bandwidth limits, stanza-too-big error condition</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0206.html"/>
+        <xmpp:version>1.4</xmpp:version>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:since>0.2.0</xmpp:since>
+        <xmpp:note>What's that about restartlogic in 1.3?</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0212.html"/>
+        <xmpp:version>1.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>required level</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0215.html"/>
+        <xmpp:version>0.7.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:note>mod_external_services</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0220.html"/>
+        <xmpp:version>1.1.1</xmpp:version>
+        <xmpp:since>0.1.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0222.html"/>
+        <xmpp:version>1.0</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_pep</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0223.html"/>
+        <xmpp:version>1.1</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_pep</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0227.html"/>
+        <xmpp:version>1.1</xmpp:version>
+        <xmpp:since>0.7.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+        <xmpp:note>Used in migrator tools and mod_storage_xep0227</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0237.html"/>
+        <xmpp:version>1.3</xmpp:version>
+        <xmpp:since>0.4.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>implied by rfc6121</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0249.html"/>
+        <xmpp:version>1.2</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_csi_simple</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0280.html"/>
+        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.10.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0286.html"/>
+        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_csi_simple</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0288.html"/>
+        <xmpp:version>1.0.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.12.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0292.html"/>
+        <xmpp:version>0.11</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:note>mod_vcard4, mod_vcard_legacy</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0297.html"/>
+        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>Used by XEP-0280, XEP-0313</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0302.html"/>
+        <xmpp:version>0.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>Core Server</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0307.html"/>
+        <xmpp:version>0.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.6.0</xmpp:since>
+        <xmpp:note>Moved into mod_muc_unique in 0.11</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0313.html"/>
+        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.10.0</xmpp:since>
+        <xmpp:note>mod_mam, mod_muc_mam</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0317.html"/>
+        <xmpp:version>0.1</xmpp:version>
+        <xmpp:status>planned</xmpp:status>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:note>muc/hats</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0318.html"/>
+        <xmpp:version>0.2</xmpp:version>
+        <xmpp:since>0.9.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>refers to inclusion of delay stamp in presence</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0352.html"/>
+        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_csi+mod_csi_simple</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0353.html"/>
+        <xmpp:version>0.4.0</xmpp:version>
+        <xmpp:since>0.11.6</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>triggers buffer flush in mod_csi_simple since 0.11.6; recognised by mod_carbons and mod_mam since 0.12</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0359.html"/>
+        <xmpp:version>0.6.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.10.0</xmpp:since>
+        <xmpp:note>Used in context of XEP-0313 by mod_mam and mod_muc_mam</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0363.html"/>
+        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:note>mod_http_file_share</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0368.html"/>
+        <xmpp:version>1.1.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.2.0</xmpp:since>
+        <xmpp:note>c2s_direct_tls_ports (formerly legacy_ssl_ports) for c2s and direct_tls_s2s_ports for s2s</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0379.html"/>
+        <xmpp:version>0.3.3</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:since>0.12.0</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0380.html"/>
+        <xmpp:version>0.3.0</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>Used in context of XEP-0352</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0384.html"/>
+        <xmpp:version>0.8.1</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>via XEP-0163, XEP-0222</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0398.html"/>
+        <xmpp:version>0.2.1</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_vcard_legacy</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0401.html"/>
+        <xmpp:version>0.3.0</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>partial</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0402.html"/>
+        <xmpp:version>1.1.3</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_bookmarks</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0410.html"/>
+        <xmpp:version>1.1.0</xmpp:version>
+        <xmpp:since>0.11.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>Server Optimization</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0411.html"/>
+        <xmpp:version>1.1.0</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_bookmarks</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0421.html"/>
+        <xmpp:version>0.1.0</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>mod_muc</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0438.html"/>
+        <xmpp:version>0.2.0</xmpp:version>
+        <xmpp:status>partial</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0441.html"/>
+        <xmpp:version>0.2.0</xmpp:version>
+        <xmpp:status>complete</xmpp:status>
+        <xmpp:note>Broken out of XEP-0313</xmpp:note>
+      </xmpp:SupportedXep>
+    </implements>
+  </Project>
+</rdf:RDF>
--- a/doc/net.server.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/doc/net.server.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -160,6 +160,26 @@
 local function addserver(address, port, listeners, pattern, sslctx)
 end
 
+--[[ Binds and listens on the given address and port
+Mostly the same as addserver but with all optional arguments in a table
+
+Arguments:
+  - address: address to bind to, may be "*" to bind all addresses. will be resolved if it is a string.
+  - port: port to bind (as number)
+  - listeners: a table of listeners
+	- config: table of extra settings
+		- read_size: the amount of bytes to read or a read pattern
+		- tls_ctx: is a valid luasec constructor
+		- tls_direct: boolean true for direct TLS, false (or nil) for starttls
+
+Returns:
+  - handle
+  - nil, "an error message": on failure (e.g. out of file descriptors)
+]]
+local function listen(address, port, listeners, config)
+end
+
+
 --[[ Wraps a lua-socket socket client socket in a handle.
 The socket must be already connected to the remote end.
 If `sslctx` is given, a SSL session will be negotiated before listeners are called.
@@ -255,4 +275,5 @@
 	closeall = closeall;
 	hook_signal = hook_signal;
 	watchfd = watchfd;
+	listen = listen;
 }
--- a/doc/storage.tld	Mon Dec 12 07:03:31 2022 +0100
+++ b/doc/storage.tld	Mon Dec 12 07:07:13 2022 +0100
@@ -47,6 +47,13 @@
 
 	-- Array of dates which do have messages (Optional?)
 	dates  : ( self, string? ) -> ({ string }) | (nil, string)
+
+	-- Map of counts per "with" field
+	summary : ( self, string?, archive_query? ) -> ( { string : integer } ) | (nil, string)
+
+	-- Map-store API
+	get    : ( self, string, string ) -> (stanza, number?, string?) | (nil, string)
+	set    : ( self, string, string, stanza, number?, string? ) -> (boolean) | (nil, string)
 end
 
 -- This represents moduleapi
--- a/makefile	Mon Dec 12 07:03:31 2022 +0100
+++ b/makefile	Mon Dec 12 07:07:13 2022 +0100
@@ -19,6 +19,9 @@
 MKDIR=install -d
 MKDIR_PRIVATE=$(MKDIR) -m750
 
+LUACHECK=luacheck
+BUSTED=busted
+
 .PHONY: all test clean install
 
 all: prosody.install prosodyctl.install prosody.cfg.lua.install prosody.version
@@ -27,39 +30,67 @@
 	$(MAKE) -C certs localhost.crt example.com.crt
 .endif
 
-install: prosody.install prosodyctl.install prosody.cfg.lua.install util/encodings.so util/encodings.so util/pposix.so util/signal.so
-	$(MKDIR) $(BIN) $(CONFIG) $(MODULES) $(SOURCE)
-	$(MKDIR_PRIVATE) $(DATA)
-	$(MKDIR) $(MAN)/man1
+install-etc: prosody.cfg.lua.install
+	$(MKDIR) $(CONFIG)
 	$(MKDIR) $(CONFIG)/certs
-	$(MKDIR) $(SOURCE)/core $(SOURCE)/net $(SOURCE)/util
+	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
+.if $(EXCERTS) == "yes"
+	$(INSTALL_DATA) certs/localhost.crt certs/localhost.key $(CONFIG)/certs
+	$(INSTALL_DATA) certs/example.com.crt certs/example.com.key $(CONFIG)/certs
+.endif
+
+install-bin: prosody.install prosodyctl.install
+	$(MKDIR) $(BIN)
 	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
 	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
+
+install-core:
+	$(MKDIR) $(SOURCE)
+	$(MKDIR) $(SOURCE)/core
 	$(INSTALL_DATA) core/*.lua $(SOURCE)/core
+
+install-net:
+	$(MKDIR) $(SOURCE)
+	$(MKDIR) $(SOURCE)/net
 	$(INSTALL_DATA) net/*.lua $(SOURCE)/net
 	$(MKDIR) $(SOURCE)/net/http $(SOURCE)/net/resolvers $(SOURCE)/net/websocket
 	$(INSTALL_DATA) net/http/*.lua $(SOURCE)/net/http
 	$(INSTALL_DATA) net/resolvers/*.lua $(SOURCE)/net/resolvers
 	$(INSTALL_DATA) net/websocket/*.lua $(SOURCE)/net/websocket
+
+install-util: util/encodings.so util/encodings.so util/pposix.so util/signal.so
+	$(MKDIR) $(SOURCE)
+	$(MKDIR) $(SOURCE)/util
 	$(INSTALL_DATA) util/*.lua $(SOURCE)/util
+	$(MAKE) install -C util-src
 	$(INSTALL_DATA) util/*.so $(SOURCE)/util
 	$(MKDIR) $(SOURCE)/util/sasl
 	$(INSTALL_DATA) util/sasl/*.lua $(SOURCE)/util/sasl
-	$(MKDIR) $(MODULES)/mod_s2s $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
+	$(MKDIR) $(SOURCE)/util/human
+	$(INSTALL_DATA) util/human/*.lua $(SOURCE)/util/human
+	$(MKDIR) $(SOURCE)/util/prosodyctl
+	$(INSTALL_DATA) util/prosodyctl/*.lua $(SOURCE)/util/prosodyctl
+
+install-plugins:
+	$(MKDIR) $(MODULES)
+	$(MKDIR) $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
 	$(INSTALL_DATA) plugins/*.lua $(MODULES)
-	$(INSTALL_DATA) plugins/mod_s2s/*.lua $(MODULES)/mod_s2s
 	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
 	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
 	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
 	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
-.if $(EXCERTS) == "yes"
-	$(INSTALL_DATA) certs/localhost.crt certs/localhost.key $(CONFIG)/certs
-	$(INSTALL_DATA) certs/example.com.crt certs/example.com.key $(CONFIG)/certs
-.endif
+
+install-man:
+	$(MKDIR) $(MAN)/man1
 	$(INSTALL_DATA) man/prosodyctl.man $(MAN)/man1/prosodyctl.1
-	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
+
+install-meta:
 	-test -f prosody.version && $(INSTALL_DATA) prosody.version $(SOURCE)/prosody.version
-	$(MAKE) install -C util-src
+
+install-data:
+	$(MKDIR_PRIVATE) $(DATA)
+
+install: install-util install-net install-core install-plugins install-bin install-etc install-man install-meta install-data
 
 clean:
 	rm -f prosody.install
@@ -68,8 +99,13 @@
 	rm -f prosody.version
 	$(MAKE) clean -C util-src
 
+lint:
+	$(LUACHECK) -q $$(HGPLAIN= hg files -I '**.lua') prosody prosodyctl
+	@echo $$(sed -n '/^\tlocal exclude_files/,/^}/p;' .luacheckrc | sed '1d;$d' | wc -l) files ignored
+	shellcheck configure
+
 test:
-	busted --lua=$(RUNWITH)
+	$(BUSTED) --lua=$(RUNWITH)
 
 
 prosody.install: prosody
--- a/man/prosodyctl.man	Mon Dec 12 07:03:31 2022 +0100
+++ b/man/prosodyctl.man	Mon Dec 12 07:07:13 2022 +0100
@@ -1,16 +1,16 @@
-.\" Automatically generated by Pandoc 1.19.2.1
+.\" Automatically generated by Pandoc 2.17.0.1
 .\"
-.TH "PROSODYCTL" "1" "2017\-09\-02" "" ""
+.TH "PROSODYCTL" "1" "2022-02-02" "" ""
 .hy
 .SH NAME
 .PP
-prosodyctl \- Manage a Prosody XMPP server
+prosodyctl - Manage a Prosody XMPP server
 .SH SYNOPSIS
 .IP
 .nf
 \f[C]
-prosodyctl\ command\ [\-\-help]
-\f[]
+prosodyctl command [--help]
+\f[R]
 .fi
 .SH DESCRIPTION
 .PP
@@ -20,30 +20,24 @@
 prosodyctl needs to be executed with sufficient privileges to perform
 its commands.
 This typically means executing prosodyctl as the root user.
-If a user named "prosody" is found then prosodyctl will change to that
-user before executing its commands.
+If a user named \[lq]prosody\[rq] is found then prosodyctl will change
+to that user before executing its commands.
 .SH COMMANDS
 .SS User Management
 .PP
 In the following commands users are identified by a Jabber ID, jid, of
-the usual form: user\@domain.
+the usual form: user\[at]domain.
 .TP
-.B adduser jid
+adduser jid
 Adds a user with Jabber ID, jid, to the server.
-You will be prompted to enter the user\[aq]s password.
-.RS
-.RE
+You will be prompted to enter the user\[cq]s password.
 .TP
-.B passwd jid
+passwd jid
 Changes the password of an existing user with Jabber ID, jid.
-You will be prompted to enter the user\[aq]s new password.
-.RS
-.RE
+You will be prompted to enter the user\[cq]s new password.
 .TP
-.B deluser jid
+deluser jid
 Deletes an existing user with Jabber ID, jid, from the server.
-.RS
-.RE
 .SS Daemon Management
 .PP
 Although prosodyctl has commands to manage the prosody daemon it is
@@ -51,136 +45,112 @@
 features if you attained Prosody through a package.
 .PP
 To perform daemon control commands prosodyctl needs a pidfile value
-specified in \f[C]/etc/prosody/prosody.cfg.lua\f[].
+specified in \f[C]/etc/prosody/prosody.cfg.lua\f[R].
 Failure to do so will cause prosodyctl to complain.
 .TP
-.B start
+start
 Starts the prosody server daemon.
 If run as root prosodyctl will attempt to change to a user named
-"prosody" before executing.
+\[lq]prosody\[rq] before executing.
 This operation will block for up to five seconds to wait for the server
 to execute.
-.RS
-.RE
 .TP
-.B stop
+stop
 Stops the prosody server daemon.
 This operation will block for up to five seconds to wait for the server
 to stop executing.
-.RS
-.RE
 .TP
-.B restart
+restart
 Restarts the prosody server daemon.
 Equivalent to running prosodyctl stop followed by prosodyctl start.
-.RS
-.RE
 .TP
-.B reload
+reload
 Signals the prosody server daemon to reload configuration and reopen log
 files.
-.RS
-.RE
 .TP
-.B status
+status
 Prints the current execution status of the prosody server daemon.
-.RS
-.RE
 .SS Certificates
 .PP
-prosodyctl can create self\-signed certificates, certificate requests
-and private keys for use with Prosody.
-Commands are of the form \f[C]prosodyctl\ cert\ subcommand\f[].
+prosodyctl can create self-signed certificates, certificate requests and
+private keys for use with Prosody.
+Commands are of the form \f[C]prosodyctl cert subcommand\f[R].
 Commands take a list of hosts to be included in the certificate.
 .TP
-.B \f[C]request\ hosts\f[]
+\f[B]\f[CB]request hosts\f[B]\f[R]
 Create a certificate request (CSR) file for submission to a certificate
 authority.
-Multiple hosts can be given, sub\-domains are automatically included.
-.RS
-.RE
+Multiple hosts can be given, sub-domains are automatically included.
 .TP
-.B \f[C]generate\ hosts\f[]
-Generate a self\-signed certificate.
-.RS
-.RE
+\f[B]\f[CB]generate hosts\f[B]\f[R]
+Generate a self-signed certificate.
 .TP
-.B \f[C]key\ host\ [size]\f[]
-Generate a private key of \[aq]size\[aq] bits (defaults to 2048).
-Invoked automatically by \[aq]request\[aq] and \[aq]generate\[aq] if
-needed.
-.RS
-.RE
+\f[B]\f[CB]key host [size]\f[B]\f[R]
+Generate a private key of `size' bits (defaults to 2048).
+Invoked automatically by `request' and `generate' if needed.
 .TP
-.B \f[C]config\ hosts\f[]
+\f[B]\f[CB]config hosts\f[B]\f[R]
 Produce a config file for the list of hosts.
-Invoked automatically by \[aq]request\[aq] and \[aq]generate\[aq] if
-needed.
-.RS
-.RE
+Invoked automatically by `request' and `generate' if needed.
 .TP
-.B \f[C]import\ hosts\ paths\f[]
+\f[B]\f[CB]import hosts paths\f[B]\f[R]
 Copy certificates for hosts into the certificate path and reload
 prosody.
-.RS
-.RE
 .SS Debugging
 .PP
 prosodyctl can also show some information about the environment,
 dependencies and such to aid in debugging.
 .TP
-.B \f[C]about\f[]
+\f[B]\f[CB]about\f[B]\f[R]
 Shows environment, various paths used by Prosody and installed
 dependencies.
-.RS
-.RE
 .TP
-.B \f[C]check\ [what]\f[]
+\f[B]\f[CB]check [what]\f[B]\f[R]
 Performs various sanity checks on the configuration, DNS setup and
 configured TLS certificates.
-\f[C]what\f[] can be one of \f[C]config\f[], \f[C]dns\f[] and
-\f[C]certs\f[] to run only that check.
-.RS
-.RE
+\f[C]what\f[R] can be one of \f[C]config\f[R], \f[C]dns\f[R]
+\f[C]certs\f[R], \f[C]disabled\f[R] and \f[C]connectivity\f[R] to run
+only that check.
 .SS Ejabberd Compatibility
 .PP
 ejabberd is another XMPP server which provides a comparable control
-tool, ejabberdctl, to control its server\[aq]s operations.
+tool, ejabberdctl, to control its server\[cq]s operations.
 prosodyctl implements some commands which are compatible with
 ejabberdctl.
 For details of how these commands work you should see ejabberdctl(8).
 .IP
 .nf
 \f[C]
-register\ user\ server\ password
+register user server password
 
-unregister\ user\ server
-\f[]
+unregister user server
+\f[R]
 .fi
 .SH OPTIONS
 .TP
-.B \f[C]\-\-config\ filename\f[]
+\f[B]\f[CB]--config filename\f[B]\f[R]
 Use the specified config file instead of the default.
-.RS
-.RE
+.TP
+\f[B]\f[CB]--root\f[B]\f[R]
+Don\[cq]t drop root privileges (e.g.\ when invoked with sudo).
+.TP
+\f[B]\f[CB]--help\f[B]\f[R]
+Display help text for the specified command.
 .TP
-.B \f[C]\-\-root\f[]
-Don\[aq]t drop root privileges.
-.RS
-.RE
+\f[B]\f[CB]--verbose\f[B]\f[R]
+Increase log level to show debug messages.
 .TP
-.B \f[C]\-\-help\f[]
-Display help text for the specified command.
-.RS
-.RE
+\f[B]\f[CB]--quiet\f[B]\f[R]
+Reduce log level to only show errors.
+.TP
+\f[B]\f[CB]--silent\f[B]\f[R]
+Disable logging completely, leaving only command output.
 .SH FILES
 .TP
-.B \f[C]/etc/prosody/prosody.cfg.lua\f[]
+\f[B]\f[CB]/etc/prosody/prosody.cfg.lua\f[B]\f[R]
 The main prosody configuration file.
 prosodyctl reads this to determine the process ID file of the prosody
 server daemon and to determine if a host has been configured.
-.RS
-.RE
 .SH ONLINE
 .PP
 More information may be found online at: <https://prosody.im/>
--- a/man/prosodyctl.markdown	Mon Dec 12 07:03:31 2022 +0100
+++ b/man/prosodyctl.markdown	Mon Dec 12 07:07:13 2022 +0100
@@ -1,24 +1,21 @@
 ---
 author:
-- 'Dwayne Bent <dbb.1@liqd.org>'
+- Dwayne Bent <dbb.1@liqd.org>
 - Kim Alvefur
-date: '2017-09-02'
+date: 2022-02-02
 section: 1
 title: PROSODYCTL
 ---
 
-NAME
-====
+# NAME
 
 prosodyctl - Manage a Prosody XMPP server
 
-SYNOPSIS
-========
+# SYNOPSIS
 
     prosodyctl command [--help]
 
-DESCRIPTION
-===========
+# DESCRIPTION
 
 prosodyctl is the control tool for the Prosody XMPP server. It may be
 used to control the server daemon and manage users.
@@ -28,11 +25,9 @@
 user. If a user named "prosody" is found then prosodyctl will change to
 that user before executing its commands.
 
-COMMANDS
-========
+# COMMANDS
 
-User Management
----------------
+## User Management
 
 In the following commands users are identified by a Jabber ID, jid, of
 the usual form: user@domain.
@@ -48,8 +43,7 @@
 deluser jid
 :   Deletes an existing user with Jabber ID, jid, from the server.
 
-Daemon Management
------------------
+## Daemon Management
 
 Although prosodyctl has commands to manage the prosody daemon it is
 recommended that you utilize your distributions daemon management
@@ -80,8 +74,7 @@
 status
 :   Prints the current execution status of the prosody server daemon.
 
-Certificates
-------------
+## Certificates
 
 prosodyctl can create self-signed certificates, certificate requests and
 private keys for use with Prosody. Commands are of the form
@@ -108,8 +101,7 @@
 :   Copy certificates for hosts into the certificate path and reload
     prosody.
 
-Debugging
----------
+## Debugging
 
 prosodyctl can also show some information about the environment,
 dependencies and such to aid in debugging.
@@ -121,10 +113,9 @@
 `check [what]`
 :   Performs various sanity checks on the configuration, DNS setup and
     configured TLS certificates. `what` can be one of `config`, `dns`
-    and `certs` to run only that check.
+    `certs`, `disabled` and `connectivity` to run only that check.
 
-Ejabberd Compatibility
-----------------------
+## Ejabberd Compatibility
 
 ejabberd is another XMPP server which provides a comparable control
 tool, ejabberdctl, to control its server's operations. prosodyctl
@@ -135,27 +126,33 @@
 
     unregister user server
 
-OPTIONS
-=======
+# OPTIONS
 
 `--config filename`
 :   Use the specified config file instead of the default.
 
 `--root`
-:   Don't drop root privileges.
+:   Don't drop root privileges (e.g. when invoked with sudo).
 
 `--help`
 :   Display help text for the specified command.
 
-FILES
-=====
+`--verbose`
+:   Increase log level to show debug messages.
+
+`--quiet`
+:   Reduce log level to only show errors.
+
+`--silent`
+:   Disable logging completely, leaving only command output.
+
+# FILES
 
 `/etc/prosody/prosody.cfg.lua`
 :   The main prosody configuration file. prosodyctl reads this to
     determine the process ID file of the prosody server daemon and to
     determine if a host has been configured.
 
-ONLINE
-======
+# ONLINE
 
 More information may be found online at: <https://prosody.im/>
--- a/net/adns.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/adns.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,13 +8,17 @@
 
 local server = require "net.server";
 local new_resolver = require "net.dns".resolver;
+local promise = require "util.promise";
 
 local log = require "util.logger".init("adns");
 
-local coroutine, tostring, pcall = coroutine, tostring, pcall;
+log("debug", "Using legacy DNS API (missing lua-unbound?)"); -- TODO write docs about luaunbound
+-- TODO Raise log level once packages are available
+
+local coroutine, pcall = coroutine, pcall;
 local setmetatable = setmetatable;
 
-local function dummy_send(sock, data, i, j) return (j-i)+1; end
+local function dummy_send(sock, data, i, j) return (j-i)+1; end -- luacheck: ignore 212
 
 local _ENV = nil;
 -- luacheck: std none
@@ -29,8 +33,7 @@
 	local peername = "<unknown>";
 	local listener = {};
 	local handler = {};
-	local err;
-	function listener.onincoming(conn, data)
+	function listener.onincoming(conn, data) -- luacheck: ignore 212/conn
 		if data then
 			resolver:feed(handler, data);
 		end
@@ -40,15 +43,18 @@
 			log("warn", "DNS socket for %s disconnected: %s", peername, err);
 			local servers = resolver.server;
 			if resolver.socketset[conn] == resolver.best_server and resolver.best_server == #servers then
-				log("error", "Exhausted all %d configured DNS servers, next lookup will try %s again", #servers, servers[1]);
+				log("warn", "Exhausted all %d configured DNS servers, next lookup will try %s again", #servers, servers[1]);
 			end
 
 			resolver:servfail(conn); -- Let the magic commence
 		end
 	end
-	handler, err = server.wrapclient(sock, "dns", 53, listener);
-	if not handler then
-		return nil, err;
+	do
+		local err;
+		handler, err = server.wrapclient(sock, "dns", 53, listener);
+		if not handler then
+			return nil, err;
+		end
 	end
 	if handler.set then
 		-- server_epoll: only watch for incoming data
@@ -76,11 +82,11 @@
 					handler(peek);
 					return;
 				end
-				log("debug", "Records for %s not in cache, sending query (%s)...", qname, tostring(coroutine.running()));
+				log("debug", "Records for %s not in cache, sending query (%s)...", qname, coroutine.running());
 				local ok, err = resolver:query(qname, qtype, qclass);
 				if ok then
 					coroutine.yield(setmetatable({ resolver, qclass or "IN", qtype or "A", qname, coroutine.running()}, query_mt)); -- Wait for reply
-					log("debug", "Reply for %s (%s)", qname, tostring(coroutine.running()));
+					log("debug", "Reply for %s (%s)", qname, coroutine.running());
 				end
 				if ok then
 					ok, err = pcall(handler, resolver:peek(qname, qtype, qclass));
@@ -89,13 +95,25 @@
 					ok, err = pcall(handler, nil, err);
 				end
 				if not ok then
-					log("error", "Error in DNS response handler: %s", tostring(err));
+					log("error", "Error in DNS response handler: %s", err);
 				end
 			end)(resolver:peek(qname, qtype, qclass));
 end
 
-function query_methods:cancel(call_handler, reason)
-	log("warn", "Cancelling DNS lookup for %s", tostring(self[4]));
+function async_resolver_methods:lookup_promise(qname, qtype, qclass)
+	return promise.new(function (resolve, reject)
+		local function handler(answer)
+			if not answer then
+				return reject();
+			end
+			resolve(answer);
+		end
+		self:lookup(handler, qname, qtype, qclass);
+	end);
+end
+
+function query_methods:cancel(call_handler, reason) -- luacheck: ignore 212/reason
+	log("warn", "Cancelling DNS lookup for %s", self[4]);
 	self[1].cancel(self[2], self[3], self[4], self[5], call_handler);
 end
 
--- a/net/connect.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/connect.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,6 +2,12 @@
 local log = require "util.logger".init("net.connect");
 local new_id = require "util.id".short;
 
+-- TODO #1246 Happy Eyeballs
+-- FIXME RFC 6724
+-- FIXME Error propagation from resolvers doesn't work
+-- FIXME #1428 Reuse DNS resolver object between service and basic resolver
+-- FIXME #1429 Close DNS resolver object when done
+
 local pending_connection_methods = {};
 local pending_connection_mt = {
 	__name = "pending_connection";
@@ -29,16 +35,17 @@
 	p.target_resolver:next(function (conn_type, ip, port, extra)
 		if not conn_type then
 			-- No more targets to try
-			p:log("debug", "No more connection targets to try");
+			p:log("debug", "No more connection targets to try", p.target_resolver.last_error);
 			if p.listeners.onfail then
-				p.listeners.onfail(p.data, p.last_error or "unable to resolve service");
+				p.listeners.onfail(p.data, p.last_error or p.target_resolver.last_error or "unable to resolve service");
 			end
 			return;
 		end
 		p:log("debug", "Next target to try is %s:%d", ip, port);
-		local conn, err = server.addclient(ip, port, pending_connection_listeners, p.options.pattern or "*a", p.options.sslctx, conn_type, extra);
+		local conn, err = server.addclient(ip, port, pending_connection_listeners, p.options.pattern or "*a",
+			extra and extra.sslctx or p.options.sslctx, conn_type, extra);
 		if not conn then
-			log("debug", "Connection attempt failed immediately: %s", tostring(err));
+			log("debug", "Connection attempt failed immediately: %s", err);
 			p.last_error = err or "unknown reason";
 			return attempt_connection(p);
 		end
--- a/net/connlisteners.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
--- COMPAT w/pre-0.9
-local log = require "util.logger".init("net.connlisteners");
-local traceback = debug.traceback;
-
-local _ENV = nil;
--- luacheck: std none
-
-local function fail()
-	log("error", "Attempt to use legacy connlisteners API. For more info see https://prosody.im/doc/developers/network");
-	log("error", "Legacy connlisteners API usage, %s", traceback("", 2));
-end
-
-return {
-	register = fail;
-	get = fail;
-	start = fail;
-	-- epic fail
-};
--- a/net/cqueues.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/cqueues.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,6 +9,7 @@
 
 local server = require "net.server";
 local cqueues = require "cqueues";
+local timer = require "util.timer";
 assert(cqueues.VERSION >= 20150113, "cqueues newer than 20150113 required")
 
 -- Create a single top level cqueue
@@ -16,55 +17,24 @@
 
 if server.cq then -- server provides cqueues object
 	cq = server.cq;
-elseif server.get_backend() == "select" and server._addtimer then -- server_select
+elseif server.watchfd then
 	cq = cqueues.new();
-	local function step()
+	local timeout = timer.add_task(cq:timeout() or 0, function ()
+		-- FIXME It should be enough to reschedule this timeout instead of replacing it, but this does not work.  See https://issues.prosody.im/1572
 		assert(cq:loop(0));
-	end
-
-	-- Use wrapclient (as wrapconnection isn't exported) to get server_select to watch cq fd
-	local handler = server.wrapclient({
-		getfd = function() return cq:pollfd(); end;
-		settimeout = function() end; -- Method just needs to exist
-		close = function() end; -- Need close method for 'closeall'
-	}, nil, nil, {});
-
-	-- Only need to listen for readable; cqueues handles everything under the hood
-	-- readbuffer is called when `select` notes an fd as readable
-	handler.readbuffer = step;
-
-	-- Use server_select low lever timer facility,
-	-- this callback gets called *every* time there is a timeout in the main loop
-	server._addtimer(function(current_time)
-		-- This may end up in extra step()'s, but cqueues handles it for us.
-		step();
 		return cq:timeout();
 	end);
-elseif server.event and server.base then -- server_event
-	cq = cqueues.new();
-	-- Only need to listen for readable; cqueues handles everything under the hood
-	local EV_READ = server.event.EV_READ;
-	-- Convert a cqueues timeout to an acceptable timeout for luaevent
-	local function luaevent_safe_timeout(cq)
+	server.watchfd(cq:pollfd(), function ()
+		assert(cq:loop(0));
 		local t = cq:timeout();
-		-- if you give luaevent 0 or nil, it re-uses the previous timeout.
-		if t == 0 then
-			t = 0.000001; -- 1 microsecond is the smallest that works (goes into a `struct timeval`)
-		elseif t == nil then -- pick something big if we don't have one
-			t = 0x7FFFFFFF; -- largest 32bit int
+		if t then
+			timer.stop(timeout);
+			timeout = timer.add_task(cq:timeout(), function ()
+				assert(cq:loop(0));
+				return cq:timeout();
+			end);
 		end
-		return t
-	end
-	local event_handle;
-	event_handle = server.base:addevent(cq:pollfd(), EV_READ, function(e)
-			-- Need to reference event_handle or this callback will get collected
-			-- This creates a circular reference that can only be broken if event_handle is manually :close()'d
-			local _ = event_handle;
-			-- Run as many cqueues things as possible (with a timeout of 0)
-			-- If an error is thrown, it will break the libevent loop; but prosody resumes after logging a top level error
-			assert(cq:loop(0));
-			return EV_READ, luaevent_safe_timeout(cq);
-		end, luaevent_safe_timeout(cq));
+	end);
 else
 	error "NYI"
 end
--- a/net/dns.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/dns.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -13,10 +13,12 @@
 
 
 local socket = require "socket";
-local timer = require "util.timer";
+local have_timer, timer = pcall(require, "util.timer");
 local new_ip = require "util.ip".new_ip;
 local have_util_net, util_net = pcall(require, "util.net");
 
+local log = require "util.logger".init("dns");
+
 local _, windows = pcall(require, "util.windows");
 local is_windows = (_ and windows) or os.getenv("WINDIR");
 
@@ -69,7 +71,9 @@
 };
 local get, set = ztact.get, ztact.set;
 
-local default_timeout = 15;
+local default_timeout = 5;
+local default_jitter = 1;
+local default_retry_jitter = 2;
 
 -------------------------------------------------- module dns
 local _ENV = nil;
@@ -340,7 +344,7 @@
 				--	4 not implemented
 				--	5 refused
 				--	6-15 reserved
-	o.z = o.z  or 0;		--  3b  0 resvered
+	o.z = o.z  or 0;		--  3b  0 reserved
 	o.ra = o.ra or 0;		--  1b  1 recursion available
 
 	o.qdcount = o.qdcount or 1;	-- 16b	number of question RRs
@@ -664,8 +668,10 @@
 -- socket layer -------------------------------------------------- socket layer
 
 
-resolver.delays = { 1, 3 };
+resolver.delays = { 1, 2, 3, 5 };
 
+resolver.jitter = have_timer and default_jitter or nil;
+resolver.retry_jitter = have_timer and default_retry_jitter or nil;
 
 function resolver:addnameserver(address)    -- - - - - - - - - - addnameserver
 	self.server = self.server or {};
@@ -853,7 +859,10 @@
 		packet = header..question,
 		server = self.best_server,
 		delay  = 1,
-		retry  = socket.gettime() + self.delays[1]
+		retry  = socket.gettime() + self.delays[1];
+		qclass = qclass;
+		qtype  = qtype;
+		qname  = qname;
 	};
 
 	-- remember the query
@@ -864,30 +873,32 @@
 	if not conn then
 		return nil, err;
 	end
-	conn:send (o.packet)
+	if self.jitter then
+		timer.add_task(math.random()*self.jitter, function ()
+			conn:send(o.packet);
+		end);
+	else
+		conn:send(o.packet);
+	end
 
 	-- remember which coroutine wants the answer
 	if co then
 		set(self.wanted, qclass, qtype, qname, co, true);
 	end
-	
-	if timer and self.timeout then
+
+	if have_timer and self.timeout then
 		local num_servers = #self.server;
 		local i = 1;
 		timer.add_task(self.timeout, function ()
 			if get(self.wanted, qclass, qtype, qname, co) then
-				if i < num_servers then
+				log("debug", "DNS request timeout %d/%d", i, num_servers)
 					i = i + 1;
-					self:servfail(conn);
-					o.server = self.best_server;
-					conn, err = self:getsocket(o.server);
-					if conn then
-						conn:send(o.packet);
-						return self.timeout;
-					end
-				end
-				-- Tried everything, failed
-				self:cancel(qclass, qtype, qname);
+					self:servfail(self.socket[o.server]);
+--				end
+			end
+			-- Still outstanding? (i.e. retried)
+			if get(self.wanted, qclass, qtype, qname, co) then
+				return self.timeout; -- Then wait
 			end
 		end)
 	end
@@ -904,6 +915,7 @@
 
 	-- Find all requests to the down server, and retry on the next server
 	self.time = socket.gettime();
+	log("debug", "servfail %d (of %d)", num, #self.server);
 	for id,queries in pairs(self.active) do
 		for question,o in pairs(queries) do
 			if o.server == num then -- This request was to the broken server
@@ -913,12 +925,27 @@
 				end
 
 				o.retries = (o.retries or 0) + 1;
-				if o.retries >= #self.server then
-					--print('timeout');
+				local retried;
+				if o.retries < #self.server then
+					sock, err = self:getsocket(o.server);
+					if sock then
+						retried = true;
+						if self.retry_jitter then
+							local delay = self.delays[((o.retries-1)%#self.delays)+1] + (math.random()*self.retry_jitter);
+							log("debug", "retry %d in %0.2fs", o.retries, delay);
+							timer.add_task(delay, function ()
+								sock:send(o.packet);
+							end);
+						else
+							log("debug", "retry %d (immediate)", o.retries);
+							sock:send(o.packet);
+						end
+					end
+				end
+				if not retried then
+					log("debug", 'tried all servers, giving up');
+					self:cancel(o.qclass, o.qtype, o.qname);
 					queries[question] = nil;
-				else
-					sock, err = self:getsocket(o.server);
-					if sock then sock:send(o.packet); end
 				end
 			end
 		end
@@ -967,7 +994,7 @@
 					-- retire the query
 					local queries = self.active[response.header.id];
 					queries[response.question.raw] = nil;
-					
+
 					if not next(queries) then self.active[response.header.id] = nil; end
 					if not next(self.active) then self:closeall(); end
 
@@ -981,7 +1008,7 @@
 						set(self.wanted, q.class, q.type, q.name, nil);
 					end
 				end
-				
+
 			end
 		end
 	end
@@ -1164,6 +1191,7 @@
 
 local _resolver = dns.resolver();
 dns._resolver = _resolver;
+_resolver.jitter, _resolver.retry_jitter = false, false;
 
 function dns.lookup(...)    -- - - - - - - - - - - - - - - - - - - - -  lookup
 	return _resolver:lookup(...);
--- a/net/http.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/http.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,6 +12,8 @@
 local util_http = require "util.http";
 local events = require "util.events";
 local verify_identity = require"util.x509".verify_identity;
+local promise = require "util.promise";
+local http_errors = require "net.http.errors";
 
 local basic_resolver = require "net.resolvers.basic";
 local connect = require "net.connect".connect;
@@ -22,6 +24,7 @@
 local pairs = pairs;
 local tonumber, tostring, traceback =
       tonumber, tostring, debug.traceback;
+local os_time = os.time;
 local xpcall = require "util.xpcall".xpcall;
 local error = error
 
@@ -40,7 +43,7 @@
 local function handleerr(err) log("error", "Traceback[http]: %s", traceback(tostring(err), 2)); return err; end
 local function log_if_failed(req, ret, ...)
 	if not ret then
-		log("error", "Request '%s': error in callback: %s", req.id, tostring((...)));
+		log("error", "Request '%s': error in callback: %s", req.id, (...));
 		if not req.suppress_errors then
 			error(...);
 		end
@@ -81,7 +84,24 @@
 			return;
 		end
 
+		local finalize_sink;
 		local function success_cb(r)
+			if r.partial then
+				-- Request should be streamed
+				log("debug", "Request '%s': partial response (%s%s)",
+					request.id,
+					r.chunked and "chunked, " or "",
+					r.body_length and ("%d bytes"):format(r.body_length) or "unknown length"
+				);
+				if request.streaming_handler then
+					log("debug", "Request '%s': Streaming via handler");
+					r.body_sink, finalize_sink = request.streaming_handler(r);
+				end
+				return;
+			elseif finalize_sink then
+				log("debug", "Request '%s': Finalizing response stream");
+				finalize_sink(r);
+			end
 			if request.callback then
 				request.callback(r.body, r.code, r, request);
 				request.callback = nil;
@@ -144,13 +164,11 @@
 		t_insert(request_line, 4, "?"..req.query);
 	end
 
+	for k, v in pairs(req.headers) do
+		t_insert(request_line, k .. ": " .. v .. "\r\n");
+	end
+	t_insert(request_line, "\r\n")
 	conn:write(t_concat(request_line));
-	local t = { [2] = ": ", [4] = "\r\n" };
-	for k, v in pairs(req.headers) do
-		t[1], t[3] = k, v;
-		conn:write(t_concat(t));
-	end
-	conn:write("\r\n");
 
 	if req.body then
 		conn:write(req.body);
@@ -161,7 +179,7 @@
 	local request = requests[conn];
 
 	if not request then
-		log("warn", "Received response from connection %s with no request attached!", tostring(conn));
+		log("warn", "Received response from connection %s with no request attached!", conn);
 		return;
 	end
 
@@ -202,6 +220,7 @@
 
 	req.url = u;
 	req.http = self;
+	req.time = os_time();
 
 	if not req.path then
 		req.path = "/";
@@ -254,6 +273,7 @@
 		end
 		req.insecure = ex.insecure;
 		req.suppress_errors = ex.suppress_errors;
+		req.streaming_handler = ex.streaming_handler;
 	end
 
 	log("debug", "Making %s %s request '%s' to %s", req.scheme:upper(), method or "GET", req.id, (ex and ex.suppress_url and host_header) or u);
@@ -267,12 +287,16 @@
 	end
 	local port_number = port and tonumber(port) or (using_https and 443 or 80);
 
+	local use_dane = self.options and self.options.use_dane;
 	local sslctx = false;
 	if using_https then
 		sslctx = ex and ex.sslctx or self.options and self.options.sslctx;
+		if ex and ex.use_dane ~= nil then
+			use_dane = ex.use_dane;
+		end
 	end
 
-	local http_service = basic_resolver.new(host, port_number, "tcp", { servername = req.host });
+	local http_service = basic_resolver.new(host, port_number, "tcp", { servername = req.host; use_dane = use_dane });
 	connect(http_service, listener, { sslctx = sslctx }, req);
 
 	self.events.fire_event("request", { http = self, request = req, url = u });
@@ -282,7 +306,22 @@
 local function new(options)
 	local http = {
 		options = options;
-		request = request;
+		request = function (self, u, ex, callback)
+			if callback ~= nil then
+				return request(self, u, ex, callback);
+			else
+				return promise.new(function (resolve, reject)
+					request(self, u, ex, function (body, code, a, b)
+						if code == 0 then
+							reject(http_errors.new(body, { request = a }));
+						else
+							a.request = b;
+							resolve(a);
+						end
+					end);
+				end);
+			end
+		end;
 		new = options and function (new_options)
 			local final_options = {};
 			for k, v in pairs(options) do final_options[k] = v; end
@@ -297,7 +336,7 @@
 end
 
 local default_http = new({
-	sslctx = { mode = "client", protocol = "sslv23", options = { "no_sslv2", "no_sslv3" } };
+	sslctx = { mode = "client", protocol = "sslv23", options = { "no_sslv2", "no_sslv3" }, alpn = "http/1.1", verify = "peer" };
 	suppress_errors = true;
 });
 
--- a/net/http/codes.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/http/codes.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -82,5 +82,5 @@
 	-- [512-599] = "Unassigned";
 };
 
-for k,v in pairs(response_codes) do response_codes[k] = k.." "..v; end
+for k,v in pairs(response_codes) do response_codes[k] = ("%03d %s"):format(k, v); end
 return setmetatable(response_codes, { __index = function(_, k) return k.." Unassigned"; end })
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/http/errors.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,119 @@
+-- This module returns a table that is suitable for use as a util.error registry,
+-- and a function to return a util.error object given callback 'code' and 'body'
+-- parameters.
+
+local codes = require "net.http.codes";
+local util_error = require "util.error";
+
+local error_templates = {
+	-- This code is used by us to report a client-side or connection error.
+	-- Instead of using the code, use the supplied body text to get one of
+	-- the more detailed errors below.
+	[0] = {
+		code = 0, type = "cancel", condition = "internal-server-error";
+		text = "Connection or internal error";
+	};
+
+	-- These are net.http built-in errors, they are returned in
+	-- the body parameter when code == 0
+	["cancelled"] = {
+		code = 0, type = "cancel", condition = "remote-server-timeout";
+		text = "Request cancelled";
+	};
+	["connection-closed"] = {
+		code = 0, type = "wait", condition = "remote-server-timeout";
+		text = "Connection closed";
+	};
+	["certificate-chain-invalid"] = {
+		code = 0, type = "cancel", condition = "remote-server-timeout";
+		text = "Server certificate not trusted";
+	};
+	["certificate-verify-failed"] = {
+		code = 0, type = "cancel", condition = "remote-server-timeout";
+		text = "Server certificate invalid";
+	};
+	["connection failed"] = {
+		code = 0, type = "cancel", condition = "remote-server-not-found";
+		text = "Connection failed";
+	};
+	["invalid-url"] = {
+		code = 0, type = "modify", condition = "bad-request";
+		text = "Invalid URL";
+	};
+	["unable to resolve service"] = {
+		code = 0, type = "cancel", condition = "remote-server-not-found";
+		text = "DNS resolution failed";
+	};
+
+	-- This doesn't attempt to map every single HTTP code (not all have sane mappings),
+	-- but all the common ones should be covered. XEP-0086 was used as reference for
+	-- most of these.
+	[400] = { type = "modify", condition = "bad-request" };
+	[401] = { type = "auth", condition = "not-authorized" };
+	[402] = { type = "auth", condition = "payment-required" };
+	[403] = { type = "auth", condition = "forbidden" };
+	[404] = { type = "cancel", condition = "item-not-found" };
+	[405] = { type = "cancel", condition = "not-allowed" };
+	[406] = { type = "modify", condition = "not-acceptable" };
+	[407] = { type = "auth", condition = "registration-required" };
+	[408] = { type = "wait", condition = "remote-server-timeout" };
+	[409] = { type = "cancel", condition = "conflict" };
+	[410] = { type = "cancel", condition = "gone" };
+	[411] = { type = "modify", condition = "bad-request" };
+	[412] = { type = "cancel", condition = "conflict" };
+	[413] = { type = "modify", condition = "resource-constraint" };
+	[414] = { type = "modify", condition = "resource-constraint" };
+	[415] = { type = "cancel", condition = "feature-not-implemented" };
+	[416] = { type = "modify", condition = "bad-request" };
+
+	[422] = { type = "modify", condition = "bad-request" };
+	[423] = { type = "wait", condition = "resource-constraint" };
+
+	[429] = { type = "wait", condition = "resource-constraint" };
+	[431] = { type = "modify", condition = "resource-constraint" };
+	[451] = { type = "auth", condition = "forbidden" };
+
+	[500] = { type = "wait", condition = "internal-server-error" };
+	[501] = { type = "cancel", condition = "feature-not-implemented" };
+	[502] = { type = "wait", condition = "remote-server-timeout" };
+	[503] = { type = "cancel", condition = "service-unavailable" };
+	[504] = { type = "wait", condition = "remote-server-timeout" };
+	[507] = { type = "wait", condition = "resource-constraint" };
+	[511] = { type = "auth", condition = "not-authorized" };
+};
+
+for k, v in pairs(codes) do
+	if error_templates[k] then
+		error_templates[k].code = k;
+		error_templates[k].text = v;
+	else
+		error_templates[k] = { type = "cancel", condition = "undefined-condition", text = v, code = k };
+	end
+end
+
+setmetatable(error_templates, {
+	__index = function(_, k)
+		if type(k) ~= "number" then
+			return nil;
+		end
+		return {
+			type = "cancel";
+			condition = "undefined-condition";
+			text = codes[k] or (k.." Unassigned");
+			code = k;
+		};
+	end
+});
+
+local function new(code, body, context)
+	if code == 0 then
+		return util_error.new(body, context, error_templates);
+	else
+		return util_error.new(code, context, error_templates);
+	end
+end
+
+return {
+	registry = error_templates;
+	new = new;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/http/files.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,149 @@
+-- Prosody IM
+-- 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.
+--
+
+local server = require"net.http.server";
+local lfs = require "lfs";
+local new_cache = require "util.cache".new;
+local log = require "util.logger".init("net.http.files");
+
+local os_date = os.date;
+local open = io.open;
+local stat = lfs.attributes;
+local build_path = require"socket.url".build_path;
+local path_sep = package.config:sub(1,1);
+
+
+local forbidden_chars_pattern = "[/%z]";
+if package.config:sub(1,1) == "\\" then
+	forbidden_chars_pattern = "[/%z\001-\031\127\"*:<>?|]"
+end
+
+local urldecode = require "util.http".urldecode;
+local function sanitize_path(path) --> util.paths or util.http?
+	if not path then return end
+	local out = {};
+
+	local c = 0;
+	for component in path:gmatch("([^/]+)") do
+		component = urldecode(component);
+		if component:find(forbidden_chars_pattern) then
+			return nil;
+		elseif component == ".." then
+			if c <= 0 then
+				return nil;
+			end
+			out[c] = nil;
+			c = c - 1;
+		elseif component ~= "." then
+			c = c + 1;
+			out[c] = component;
+		end
+	end
+	if path:sub(-1,-1) == "/" then
+		out[c+1] = "";
+	end
+	return "/"..table.concat(out, "/");
+end
+
+local function serve(opts)
+	if type(opts) ~= "table" then -- assume path string
+		opts = { path = opts };
+	end
+	local mime_map = opts.mime_map or { html = "text/html" };
+	local cache = new_cache(opts.cache_size or 256);
+	local cache_max_file_size = tonumber(opts.cache_max_file_size) or 1024
+	-- luacheck: ignore 431
+	local base_path = opts.path;
+	local dir_indices = opts.index_files or { "index.html", "index.htm" };
+	local directory_index = opts.directory_index;
+	local function serve_file(event, path)
+		local request, response = event.request, event.response;
+		local sanitized_path = sanitize_path(path);
+		if path and not sanitized_path then
+			return 400;
+		end
+		path = sanitized_path;
+		local orig_path = sanitize_path(request.path);
+		local full_path = base_path .. (path or ""):gsub("/", path_sep);
+		local attr = stat(full_path:match("^.*[^\\/]")); -- Strip trailing path separator because Windows
+		if not attr then
+			return 404;
+		end
+
+		local request_headers, response_headers = request.headers, response.headers;
+
+		local last_modified = os_date('!%a, %d %b %Y %H:%M:%S GMT', attr.modification);
+		response_headers.last_modified = last_modified;
+
+		local etag = ('"%x-%x-%x"'):format(attr.change or 0, attr.size or 0, attr.modification or 0);
+		response_headers.etag = etag;
+
+		local if_none_match = request_headers.if_none_match
+		local if_modified_since = request_headers.if_modified_since;
+		if etag == if_none_match
+		or (not if_none_match and last_modified == if_modified_since) then
+			return 304;
+		end
+
+		local data = cache:get(orig_path);
+		if data and data.etag == etag then
+			response_headers.content_type = data.content_type;
+			data = data.data;
+			cache:set(orig_path, data);
+		elseif attr.mode == "directory" and path then
+			if full_path:sub(-1) ~= "/" then
+				local dir_path = { is_absolute = true, is_directory = true };
+				for dir in orig_path:gmatch("[^/]+") do dir_path[#dir_path+1]=dir; end
+				response_headers.location = build_path(dir_path);
+				return 301;
+			end
+			for i=1,#dir_indices do
+				if stat(full_path..dir_indices[i], "mode") == "file" then
+					return serve_file(event, path..dir_indices[i]);
+				end
+			end
+
+			if directory_index then
+				data = server._events.fire_event("directory-index", { path = request.path, full_path = full_path });
+			end
+			if not data then
+				return 403;
+			end
+			cache:set(orig_path, { data = data, content_type = mime_map.html; etag = etag; });
+			response_headers.content_type = mime_map.html;
+
+		else
+			local f, err = open(full_path, "rb");
+			if not f then
+				log("debug", "Could not open %s. Error was %s", full_path, err);
+				return 403;
+			end
+			local ext = full_path:match("%.([^./]+)$");
+			local content_type = ext and mime_map[ext];
+			response_headers.content_type = content_type;
+			if attr.size > cache_max_file_size then
+				response_headers.content_length = ("%d"):format(attr.size);
+				log("debug", "%d > cache_max_file_size", attr.size);
+				return response:send_file(f);
+			else
+				data = f:read("*a");
+				f:close();
+			end
+			cache:set(orig_path, { data = data; content_type = content_type; etag = etag });
+		end
+
+		return response:send(data);
+	end
+
+	return serve_file;
+end
+
+return {
+	serve = serve;
+}
+
--- a/net/http/parser.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/http/parser.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,8 +1,8 @@
 local tonumber = tonumber;
 local assert = assert;
-local t_insert, t_concat = table.insert, table.concat;
 local url_parse = require "socket.url".parse;
 local urldecode = require "util.http".urldecode;
+local dbuffer = require "util.dbuffer";
 
 local function preprocess_path(path)
 	path = urldecode((path:gsub("//+", "/")));
@@ -28,10 +28,13 @@
 function httpstream.new(success_cb, error_cb, parser_type, options_cb)
 	local client = true;
 	if not parser_type or parser_type == "server" then client = false; else assert(parser_type == "client", "Invalid parser type"); end
-	local buf, buflen, buftable = {}, 0, true;
 	local bodylimit = tonumber(options_cb and options_cb().body_size_limit) or 10*1024*1024;
+	-- https://stackoverflow.com/a/686243
+	-- Individual headers can be up to 16k? What madness?
+	local headlimit = tonumber(options_cb and options_cb().head_size_limit) or 10*1024;
 	local buflimit = tonumber(options_cb and options_cb().buffer_size_limit) or bodylimit * 2;
-	local chunked, chunk_size, chunk_start;
+	local buffer = dbuffer.new(buflimit);
+	local chunked;
 	local state = nil;
 	local packet;
 	local len;
@@ -41,32 +44,27 @@
 		feed = function(_, data)
 			if error then return nil, "parse has failed"; end
 			if not data then -- EOF
-				if buftable then buf, buftable = t_concat(buf), false; end
 				if state and client and not len then -- reading client body until EOF
-					packet.body = buf;
+					buffer:collapse();
+					packet.body = buffer:read_chunk() or "";
+					packet.partial = nil;
 					success_cb(packet);
-				elseif buf ~= "" then -- unexpected EOF
+					state = nil;
+				elseif buffer:length() ~= 0 then -- unexpected EOF
 					error = true; return error_cb("unexpected-eof");
 				end
 				return;
 			end
-			if buftable then
-				t_insert(buf, data);
-			else
-				buf = { buf, data };
-				buftable = true;
-			end
-			buflen = buflen + #data;
-			if buflen > buflimit then error = true; return error_cb("max-buffer-size-exceeded"); end
-			while buflen > 0 do
+			if not buffer:write(data) then error = true; return error_cb("max-buffer-size-exceeded"); end
+			while buffer:length() > 0 do
 				if state == nil then -- read request
-					if buftable then buf, buftable = t_concat(buf), false; end
-					local index = buf:find("\r\n\r\n", nil, true);
+					local index = buffer:sub(1, headlimit):find("\r\n\r\n", nil, true);
 					if not index then return; end -- not enough data
-					local method, path, httpversion, status_code, reason_phrase;
+					-- FIXME was reason_phrase meant to be passed on somewhere?
+					local method, path, httpversion, status_code, reason_phrase; -- luacheck: ignore reason_phrase
 					local first_line;
 					local headers = {};
-					for line in buf:sub(1,index+1):gmatch("([^\r\n]+)\r\n") do -- parse request
+					for line in buffer:read(index+3):gmatch("([^\r\n]+)\r\n") do -- parse request
 						if first_line then
 							local key, val = line:match("^([^%s:]+): *(.*)$");
 							if not key then error = true; return error_cb("invalid-header-line"); end -- TODO handle multi-line and invalid headers
@@ -91,7 +89,6 @@
 					if not first_line then error = true; return error_cb("invalid-status-line"); end
 					chunked = have_body and headers["transfer-encoding"] == "chunked";
 					len = tonumber(headers["content-length"]); -- TODO check for invalid len
-					if len and len > bodylimit then error = true; return error_cb("content-length-limit-exceeded"); end
 					if client then
 						-- FIXME handle '100 Continue' response (by skipping it)
 						if not have_body then len = 0; end
@@ -99,7 +96,10 @@
 							code = status_code;
 							httpversion = httpversion;
 							headers = headers;
-							body = have_body and "" or nil;
+							body = false;
+							body_length = len;
+							chunked = chunked;
+							partial = true;
 							-- COMPAT the properties below are deprecated
 							responseversion = httpversion;
 							responseheaders = headers;
@@ -124,60 +124,81 @@
 							path = path;
 							httpversion = httpversion;
 							headers = headers;
-							body = nil;
+							body = false;
+							body_sink = nil;
+							chunked = chunked;
+							partial = true;
 						};
 					end
-					buf = buf:sub(index + 4);
-					buflen = #buf;
+					if len and len > bodylimit then
+						-- Early notification, for redirection
+						success_cb(packet);
+						if not packet.body_sink then error = true; return error_cb("content-length-limit-exceeded"); end
+					end
+					if chunked and not packet.body_sink then
+						success_cb(packet);
+						if not packet.body_sink then
+							packet.body_buffer = dbuffer.new(buflimit);
+						end
+					end
 					state = true;
 				end
 				if state then -- read body
-					if client then
-						if chunked then
-							if chunk_start and buflen - chunk_start - 2 < chunk_size then
-								return;
-							end -- not enough data
-							if buftable then buf, buftable = t_concat(buf), false; end
-							if not buf:find("\r\n", nil, true) then
-								return;
-							end -- not enough data
-							if not chunk_size then
-								chunk_size, chunk_start = buf:match("^(%x+)[^\r\n]*\r\n()");
-								chunk_size = chunk_size and tonumber(chunk_size, 16);
-								if not chunk_size then error = true; return error_cb("invalid-chunk-size"); end
+					if chunked then
+						local chunk_header = buffer:sub(1, 512); -- XXX How large do chunk headers grow?
+						local chunk_size, chunk_start = chunk_header:match("^(%x+)[^\r\n]*\r\n()");
+						if not chunk_size then return; end
+						chunk_size = chunk_size and tonumber(chunk_size, 16);
+						if not chunk_size then error = true; return error_cb("invalid-chunk-size"); end
+						if chunk_size == 0 and chunk_header:find("\r\n\r\n", chunk_start-2, true) then
+							local body_buffer = packet.body_buffer;
+							if body_buffer then
+								packet.body_buffer = nil;
+								body_buffer:collapse();
+								packet.body = body_buffer:read_chunk() or "";
 							end
-							if chunk_size == 0 and buf:find("\r\n\r\n", chunk_start-2, true) then
-								state, chunk_size = nil, nil;
-								buf = buf:gsub("^.-\r\n\r\n", ""); -- This ensure extensions and trailers are stripped
-								success_cb(packet);
-							elseif buflen - chunk_start - 2 >= chunk_size then -- we have a chunk
-								packet.body = packet.body..buf:sub(chunk_start, chunk_start + (chunk_size-1));
-								buf = buf:sub(chunk_start + chunk_size + 2);
-								buflen = buflen - (chunk_start + chunk_size + 2 - 1);
-								chunk_size, chunk_start = nil, nil;
-							else -- Partial chunk remaining
-								break;
-							end
-						elseif len and buflen >= len then
-							if buftable then buf, buftable = t_concat(buf), false; end
-							if packet.code == 101 then
-								packet.body, buf, buflen, buftable = buf, {}, 0, true;
-							else
-								packet.body, buf = buf:sub(1, len), buf:sub(len + 1);
-								buflen = #buf;
-							end
-							state = nil; success_cb(packet);
-						else
+
+							buffer:collapse();
+							local buf = buffer:read_chunk();
+							buf = buf:gsub("^.-\r\n\r\n", ""); -- This ensure extensions and trailers are stripped
+							buffer:write(buf);
+							state, chunked = nil, nil;
+							packet.partial = nil;
+							success_cb(packet);
+						elseif buffer:length() - chunk_start - 2 >= chunk_size then -- we have a chunk
+							buffer:discard(chunk_start - 1); -- TODO verify that it's not off-by-one
+							(packet.body_sink or packet.body_buffer):write(buffer:read(chunk_size));
+							buffer:discard(2); -- CRLF
+						else -- Partial chunk remaining
 							break;
 						end
-					elseif buflen >= len then
-						if buftable then buf, buftable = t_concat(buf), false; end
-						packet.body, buf = buf:sub(1, len), buf:sub(len + 1);
-						buflen = #buf;
-						state = nil; success_cb(packet);
+					elseif packet.body_sink then
+						local chunk = buffer:read_chunk(len);
+						while chunk and len > 0 do
+							if packet.body_sink:write(chunk) then
+								len = len - #chunk;
+								chunk = buffer:read_chunk(len);
+							else
+								error = true;
+								return error_cb("body-sink-write-failure");
+							end
+						end
+						if len == 0 then
+							state = nil;
+							packet.partial = nil;
+							success_cb(packet);
+						end
+					elseif buffer:length() >= len then
+						assert(not chunked)
+						packet.body = buffer:read(len) or "";
+						state = nil;
+						packet.partial = nil;
+						success_cb(packet);
 					else
 						break;
 					end
+				else
+					break;
 				end
 			end
 		end;
--- a/net/http/server.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/http/server.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,5 +1,5 @@
 
-local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
+local t_insert, t_concat = table.insert, table.concat;
 local parser_new = require "net.http.parser".new;
 local events = require "util.events".new();
 local addserver = require "net.server".addserver;
@@ -8,12 +8,12 @@
 local pairs = pairs;
 local s_upper = string.upper;
 local setmetatable = setmetatable;
-local xpcall = require "util.xpcall".xpcall;
-local traceback = debug.traceback;
-local tostring = tostring;
 local cache = require "util.cache";
 local codes = require "net.http.codes";
+local promise = require "util.promise";
+local errors = require "util.error";
 local blocksize = 2^16;
+local async = require "util.async";
 
 local _M = {};
 
@@ -89,51 +89,60 @@
 
 local handle_request;
 
-local last_err;
-local function _traceback_handler(err) last_err = err; log("error", "Traceback[httpserver]: %s", traceback(tostring(err), 2)); end
 events.add_handler("http-error", function (error)
 	return "Error processing request: "..codes[error.code]..". Check your error log for more information.";
 end, -1);
 
+local runner_callbacks = {};
+
+function runner_callbacks:ready()
+	self.data.conn:resume();
+end
+
+function runner_callbacks:waiting()
+	self.data.conn:pause();
+end
+
+function runner_callbacks:error(err)
+	log("error", "Traceback[httpserver]: %s", err);
+	self.data.conn:write("HTTP/1.0 500 Internal Server Error\r\n\r\n"..events.fire_event("http-error", { code = 500, private_message = err }));
+	self.data.conn:close();
+end
+
+local function noop() end
 function listener.onconnect(conn)
+	local session = { conn = conn };
 	local secure = conn:ssl() and true or nil;
-	local pending = {};
-	local waiting = false;
-	local function process_next()
-		if waiting then return; end -- log("debug", "can't process_next, waiting");
-		waiting = true;
-		while sessions[conn] and #pending > 0 do
-			local request = t_remove(pending);
-			--log("debug", "process_next: %s", request.path);
-			if not xpcall(handle_request, _traceback_handler, conn, request, process_next) then
-				conn:write("HTTP/1.0 500 Internal Server Error\r\n\r\n"..events.fire_event("http-error", { code = 500, private_message = last_err }));
-				conn:close();
-			end
+	local ip = conn:ip();
+	session.thread = async.runner(function (request)
+		local wait, done;
+		if request.partial == true then
+			-- Have the header for a request, we want to receive the rest
+			-- when we've decided where the data should go.
+			wait, done = noop, noop;
+		else -- Got the entire request
+			-- Hold off on receiving more incoming requests until this one has been handled.
+			wait, done = async.waiter();
 		end
-		--log("debug", "ready for more");
-		waiting = false;
-	end
+		handle_request(conn, request, done); wait();
+	end, runner_callbacks, session);
 	local function success_cb(request)
 		--log("debug", "success_cb: %s", request.path);
-		if waiting then
-			log("error", "http connection handler is not reentrant: %s", request.path);
-			assert(false, "http connection handler is not reentrant");
-		end
+		request.ip = ip;
 		request.secure = secure;
-		t_insert(pending, request);
-		process_next();
+		session.thread:run(request);
 	end
 	local function error_cb(err)
 		log("debug", "error_cb: %s", err or "<nil>");
 		-- FIXME don't close immediately, wait until we process current stuff
 		-- FIXME if err, send off a bad-request response
-		sessions[conn] = nil;
 		conn:close();
 	end
 	local function options_cb()
 		return options;
 	end
-	sessions[conn] = parser_new(success_cb, error_cb, "server", options_cb);
+	session.parser = parser_new(success_cb, error_cb, "server", options_cb);
+	sessions[conn] = session;
 end
 
 function listener.ondisconnect(conn)
@@ -152,7 +161,7 @@
 end
 
 function listener.onincoming(conn, data)
-	sessions[conn]:feed(data);
+	sessions[conn].parser:feed(data);
 end
 
 function listener.ondrain(conn)
@@ -170,6 +179,49 @@
 	end
 });
 
+local function handle_result(request, response, result)
+	if result == nil then
+		result = 404;
+	end
+
+	if result == true then
+		return;
+	end
+
+	local body;
+	local result_type = type(result);
+	if result_type == "number" then
+		response.status_code = result;
+		if result >= 400 then
+			body = events.fire_event("http-error", { request = request, response = response, code = result });
+		end
+	elseif result_type == "string" then
+		body = result;
+	elseif errors.is_err(result) then
+		response.status_code = result.code or 500;
+		body = events.fire_event("http-error", { request = request, response = response, code = result.code or 500, error = result });
+	elseif promise.is_promise(result) then
+		result:next(function (ret)
+			handle_result(request, response, ret);
+		end, function (err)
+			response.status_code = 500;
+			handle_result(request, response, err or 500);
+		end);
+		return true;
+	elseif result_type == "table" then
+		for k, v in pairs(result) do
+			if k ~= "headers" then
+				response[k] = v;
+			else
+				for header_name, header_value in pairs(v) do
+					response.headers[header_name] = header_value;
+				end
+			end
+		end
+	end
+	return response:send(body);
+end
+
 function _M.hijack_response(response, listener) -- luacheck: ignore
 	error("TODO");
 end
@@ -194,13 +246,17 @@
 		response_conn_header = httpversion == "1.1" and "close" or nil
 	end
 
+	local is_head_request = request.method == "HEAD";
+
 	local response = {
 		request = request;
+		is_head_request = is_head_request;
 		status_code = 200;
 		headers = { date = date_header, connection = response_conn_header };
 		persistent = persistent;
 		conn = conn;
 		send = _M.send_response;
+		write_headers = _M.write_headers;
 		send_file = _M.send_file;
 		done = _M.finish_response;
 		finish_cb = finish_cb;
@@ -227,6 +283,11 @@
 	local payload = { request = request, response = response };
 	log("debug", "Firing event: %s", global_event);
 	local result = events.fire_event(global_event, payload);
+	if result == nil and is_head_request then
+		local global_head_event = "GET "..request.path:match("[^?]*");
+		log("debug", "Firing event: %s", global_head_event);
+		result = events.fire_event(global_head_event, payload);
+	end
 	if result == nil then
 		if not hosts[host] then
 			if hosts[default_host] then
@@ -247,40 +308,17 @@
 		local host_event = request.method.." "..host..request.path:match("[^?]*");
 		log("debug", "Firing event: %s", host_event);
 		result = events.fire_event(host_event, payload);
-	end
-	if result ~= nil then
-		if result ~= true then
-			local body;
-			local result_type = type(result);
-			if result_type == "number" then
-				response.status_code = result;
-				if result >= 400 then
-					payload.code = result;
-					body = events.fire_event("http-error", payload);
-				end
-			elseif result_type == "string" then
-				body = result;
-			elseif result_type == "table" then
-				for k, v in pairs(result) do
-					if k ~= "headers" then
-						response[k] = v;
-					else
-						for header_name, header_value in pairs(v) do
-							response.headers[header_name] = header_value;
-						end
-					end
-				end
-			end
-			response:send(body);
+
+		if result == nil and is_head_request then
+			local host_head_event = "GET "..host..request.path:match("[^?]*");
+			log("debug", "Firing event: %s", host_head_event);
+			result = events.fire_event(host_head_event, payload);
 		end
-		return;
 	end
 
-	-- if handler not called, return 404
-	response.status_code = 404;
-	payload.code = 404;
-	response:send(events.fire_event("http-error", payload));
+	return handle_result(request, response, result);
 end
+
 local function prepare_header(response)
 	local status_line = "HTTP/"..response.request.httpversion.." "..(response.status or codes[response.status_code]);
 	local headers = response.headers;
@@ -292,12 +330,25 @@
 	return output;
 end
 _M.prepare_header = prepare_header;
+function _M.write_headers(response)
+	if response.finished then return; end
+	local output = prepare_header(response);
+	response.conn:write(t_concat(output));
+end
+function _M.send_head_response(response)
+	if response.finished then return; end
+	_M.write_headers(response);
+	response:done();
+end
 function _M.send_response(response, body)
 	if response.finished then return; end
 	body = body or response.body or "";
 	-- Per RFC 7230, informational (1xx) and 204 (no content) should have no c-l header
 	if response.status_code > 199 and response.status_code ~= 204 then
-		response.headers.content_length = #body;
+		response.headers.content_length = ("%d"):format(#body);
+	end
+	if response.is_head_request then
+		return _M.send_head_response(response)
 	end
 	local output = prepare_header(response);
 	t_insert(output, body);
@@ -305,6 +356,10 @@
 	response:done();
 end
 function _M.send_file(response, f)
+	if response.is_head_request then
+		if f.close then f:close(); end
+		return _M.send_head_response(response);
+	end
 	if response.finished then return; end
 	local chunked = not response.headers.content_length;
 	if chunked then response.headers.transfer_encoding = "chunked"; end
@@ -331,7 +386,7 @@
 			return response:done();
 		end
 	end
-	response.conn:write(t_concat(prepare_header(response)));
+	_M.write_headers(response);
 	return true;
 end
 function _M.finish_response(response)
--- a/net/resolvers/basic.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/resolvers/basic.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,10 +2,13 @@
 local inet_pton = require "util.net".pton;
 local inet_ntop = require "util.net".ntop;
 local idna_to_ascii = require "util.encodings".idna.to_ascii;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
 
 local methods = {};
 local resolver_mt = { __index = methods };
 
+-- FIXME RFC 6724
+
 -- Find the next target to connect to, and
 -- pass it to cb()
 function methods:next(cb)
@@ -20,39 +23,96 @@
 	end
 
 	if not self.hostname then
-		-- FIXME report IDNA error
+		self.last_error = "hostname failed IDNA";
 		cb(nil);
 		return;
 	end
 
+	local secure = true;
+	local tlsa = {};
 	local targets = {};
-	local n = 2;
+	local n = 3;
 	local function ready()
 		n = n - 1;
 		if n > 0 then return; end
 		self.targets = targets;
+		if self.extra and self.extra.use_dane then
+			if secure and tlsa[1] then
+				self.extra.tlsa = tlsa;
+				self.extra.dane_hostname = self.hostname;
+			else
+				self.extra.tlsa = nil;
+				self.extra.dane_hostname = nil;
+			end
+		end
 		self:next(cb);
 	end
 
 	-- Resolve DNS to target list
 	local dns_resolver = adns.resolver();
-	dns_resolver:lookup(function (answer)
-		if answer then
-			for _, record in ipairs(answer) do
-				table.insert(targets, { self.conn_type.."4", record.a, self.port, self.extra });
+
+	if not self.extra or self.extra.use_ipv4 ~= false then
+		dns_resolver:lookup(function (answer, err)
+			if answer then
+				secure = secure and answer.secure;
+				for _, record in ipairs(answer) do
+					table.insert(targets, { self.conn_type.."4", record.a, self.port, self.extra });
+				end
+				if answer.bogus then
+					self.last_error = "Validation error in A lookup";
+				elseif answer.status then
+					self.last_error = answer.status .. " in A lookup";
+				end
+			else
+				self.last_error = err;
 			end
-		end
+			ready();
+		end, self.hostname, "A", "IN");
+	else
 		ready();
-	end, self.hostname, "A", "IN");
+	end
 
-	dns_resolver:lookup(function (answer)
-		if answer then
-			for _, record in ipairs(answer) do
-				table.insert(targets, { self.conn_type.."6", record.aaaa, self.port, self.extra });
+	if not self.extra or self.extra.use_ipv6 ~= false then
+		dns_resolver:lookup(function (answer, err)
+			if answer then
+				secure = secure and answer.secure;
+				for _, record in ipairs(answer) do
+					table.insert(targets, { self.conn_type.."6", record.aaaa, self.port, self.extra });
+				end
+				if answer.bogus then
+					self.last_error = "Validation error in AAAA lookup";
+				elseif answer.status then
+					self.last_error = answer.status .. " in AAAA lookup";
+				end
+			else
+				self.last_error = err;
 			end
-		end
+			ready();
+		end, self.hostname, "AAAA", "IN");
+	else
 		ready();
-	end, self.hostname, "AAAA", "IN");
+	end
+
+	if self.extra and self.extra.use_dane == true then
+		dns_resolver:lookup(function (answer, err)
+			if answer then
+				secure = secure and answer.secure;
+				for _, record in ipairs(answer) do
+					table.insert(tlsa, record.tlsa);
+				end
+				if answer.bogus then
+					self.last_error = "Validation error in TLSA lookup";
+				elseif answer.status then
+					self.last_error = answer.status .. " in TLSA lookup";
+				end
+			else
+				self.last_error = err;
+			end
+			ready();
+		end, ("_%d._tcp.%s"):format(self.port, self.hostname), "TLSA", "IN");
+	else
+		ready();
+	end
 end
 
 local function new(hostname, port, conn_type, extra)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/resolvers/chain.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,38 @@
+
+local methods = {};
+local resolver_mt = { __index = methods };
+
+-- Find the next target to connect to, and
+-- pass it to cb()
+function methods:next(cb)
+	if self.resolvers then
+		if not self.resolver then
+			if #self.resolvers == 0 then
+				cb(nil);
+				return;
+			end
+			local next_resolver = table.remove(self.resolvers, 1);
+			self.resolver = next_resolver;
+		end
+		self.resolver:next(function (...)
+			if self.resolver then
+				self.last_error = self.resolver.last_error;
+			end
+			if ... == nil then
+				self.resolver = nil;
+				self:next(cb);
+			else
+				cb(...);
+			end
+		end);
+		return;
+	end
+end
+
+local function new(resolvers)
+	return setmetatable({ resolvers = resolvers }, resolver_mt);
+end
+
+return {
+	new = new;
+};
--- a/net/resolvers/manual.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/resolvers/manual.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,5 +1,6 @@
 local methods = {};
 local resolver_mt = { __index = methods };
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
 
 -- Find the next target to connect to, and
 -- pass it to cb()
--- a/net/resolvers/service.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/resolvers/service.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,6 +1,8 @@
 local adns = require "net.adns";
 local basic = require "net.resolvers.basic";
+local inet_pton = require "util.net".pton;
 local idna_to_ascii = require "util.encodings".idna.to_ascii;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
 
 local methods = {};
 local resolver_mt = { __index = methods };
@@ -9,14 +11,20 @@
 -- pass it to cb()
 function methods:next(cb)
 	if self.targets then
-		if #self.targets == 0 then
-			cb(nil);
-			return;
+		if not self.resolver then
+			if #self.targets == 0 then
+				cb(nil);
+				return;
+			end
+			local next_target = table.remove(self.targets, 1);
+			self.resolver = basic.new(unpack(next_target, 1, 4));
 		end
-		local next_target = table.remove(self.targets, 1);
-		self.resolver = basic.new(unpack(next_target, 1, 4));
 		self.resolver:next(function (...)
+			if self.resolver then
+				self.last_error = self.resolver.last_error;
+			end
 			if ... == nil then
+				self.resolver = nil;
 				self:next(cb);
 			else
 				cb(...);
@@ -26,7 +34,7 @@
 	end
 
 	if not self.hostname then
-		-- FIXME report IDNA error
+		self.last_error = "hostname failed IDNA";
 		cb(nil);
 		return;
 	end
@@ -39,17 +47,32 @@
 
 	-- Resolve DNS to target list
 	local dns_resolver = adns.resolver();
-	dns_resolver:lookup(function (answer)
+	dns_resolver:lookup(function (answer, err)
+		if not answer and not err then
+			-- net.adns returns nil if there are zero records or nxdomain
+			answer = {};
+		end
 		if answer then
+			if self.extra and not answer.secure then
+				self.extra.use_dane = false;
+			elseif answer.bogus then
+				self.last_error = "Validation error in SRV lookup";
+				ready();
+				return;
+			end
+
 			if #answer == 0 then
 				if self.extra and self.extra.default_port then
 					table.insert(targets, { self.hostname, self.extra.default_port, self.conn_type, self.extra });
+				else
+					self.last_error = "zero SRV records found";
 				end
 				ready();
 				return;
 			end
 
 			if #answer == 1 and answer[1].srv.target == "." then -- No service here
+				self.last_error = "service explicitly unavailable";
 				ready();
 				return;
 			end
@@ -58,12 +81,22 @@
 			for _, record in ipairs(answer) do
 				table.insert(targets, { record.srv.target, record.srv.port, self.conn_type, self.extra });
 			end
+		else
+			self.last_error = err;
 		end
 		ready();
 	end, "_" .. self.service .. "._" .. self.conn_type .. "." .. self.hostname, "SRV", "IN");
 end
 
 local function new(hostname, service, conn_type, extra)
+	local is_ip = inet_pton(hostname);
+	if not is_ip and hostname:sub(1,1) == '[' then
+		is_ip = inet_pton(hostname:sub(2,-2));
+	end
+	if is_ip and extra and extra.default_port then
+		return basic.new(hostname, extra.default_port, conn_type, extra);
+	end
+
 	return setmetatable({
 		hostname = idna_to_ascii(hostname);
 		service = service;
--- a/net/server.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/server.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -13,7 +13,10 @@
 end
 
 local log = require "util.logger".init("net.server");
-local server_type = require "core.configmanager".get("*", "network_backend") or "select";
+
+local default_backend = "epoll";
+
+local server_type = require "core.configmanager".get("*", "network_backend") or default_backend;
 
 if require "core.configmanager".get("*", "use_libevent") then
 	server_type = "event";
@@ -21,8 +24,8 @@
 
 if server_type == "event" then
 	if not pcall(require, "luaevent.core") then
-		log("error", "libevent not found, falling back to select()");
-		server_type = "select"
+		log("error", "libevent not found, falling back to %s", default_backend);
+		server_type = default_backend;
 	end
 end
 
@@ -56,6 +59,8 @@
 		end
 	end
 elseif server_type == "select" then
+	-- TODO Remove completely.
+	log("warn", "select is deprecated, the new default is epoll. For more info see https://prosody.im/doc/network_backend");
 	server = require "net.server_select";
 
 	local defaults = {};
--- a/net/server_epoll.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/server_epoll.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,20 +9,25 @@
 local t_insert = table.insert;
 local t_concat = table.concat;
 local setmetatable = setmetatable;
-local tostring = tostring;
 local pcall = pcall;
 local type = type;
 local next = next;
 local pairs = pairs;
-local log = require "util.logger".init("server_epoll");
+local ipairs = ipairs;
+local traceback = debug.traceback;
+local logger = require "util.logger";
+local log = logger.init("server_epoll");
 local socket = require "socket";
 local luasec = require "ssl";
-local gettime = require "util.time".now;
+local realtime = require "util.time".now;
+local monotonic = require "util.time".monotonic;
 local indexedbheap = require "util.indexedbheap";
 local createtable = require "util.table".create;
 local inet = require "util.net";
 local inet_pton = inet.pton;
 local _SOCKETINVALID = socket._SOCKETINVALID or -1;
+local new_id = require "util.id".short;
+local xpcall = require "util.xpcall".xpcall;
 
 local poller = require "util.poll"
 local EEXIST = poller.EEXIST;
@@ -38,7 +43,10 @@
 	read_timeout = 14 * 60;
 
 	-- How long to wait for a socket to become writable after queuing data to send
-	send_timeout = 60;
+	send_timeout = 180;
+
+	-- How long to wait for a socket to become writable after creation
+	connect_timeout = 20;
 
 	-- Some number possibly influencing how many pending connections can be accepted
 	tcp_backlog = 128;
@@ -46,7 +54,7 @@
 	-- If accepting a new incoming connection fails, wait this long before trying again
 	accept_retry_interval = 10;
 
-	-- If there is still more data to read from LuaSocktes buffer, wait this long and read again
+	-- If there is still more data to read from LuaSockets buffer, wait this long and read again
 	read_retry_delay = 1e-06;
 
 	-- Size of chunks to read from sockets
@@ -57,7 +65,29 @@
 
 	-- Maximum and minimum amount of time to sleep waiting for events (adjusted for pending timers)
 	max_wait = 86400;
-	min_wait = 1e-06;
+	min_wait = 0.001;
+
+	-- Enable extra noisy debug logging
+	verbose = false;
+
+	-- EXPERIMENTAL
+	-- Whether to kill connections in case of callback errors.
+	fatal_errors = false;
+
+	-- Or disable protection (like server_select) for potential performance gains
+	protect_listeners = true;
+
+	-- Attempt writes instantly
+	opportunistic_writes = false;
+
+	-- TCP Keepalives
+	tcp_keepalive = false; -- boolean | number
+
+	-- Whether to let the Nagle algorithm stay enabled
+	nagle = true;
+
+	-- Reuse write buffer tables
+	keep_buffers = true;
 
 	--- How long to wait after getting the shutdown signal before forcefully tearing down every socket
 	shutdown_deadline = 5;
@@ -71,54 +101,62 @@
 local timers = indexedbheap.create();
 
 local function noop() end
-local function closetimer(t)
-	t[1] = 0;
-	t[2] = noop;
-	timers:remove(t.id);
+
+-- Keep track of recently closed timers to avoid re-adding them
+local closedtimers = {};
+
+local function closetimer(id)
+	if timers:remove(id) then
+		closedtimers[id] = true;
+	end
 end
 
-local function reschedule(t, time)
-	t[1] = time;
-	timers:reprioritize(t.id, time);
-end
-
--- Add absolute timer
-local function at(time, f)
-	local timer = { time, f, close = closetimer, reschedule = reschedule, id = nil };
-	timer.id = timers:insert(timer, time);
-	return timer;
+local function reschedule(id, time)
+	time = monotonic() + time;
+	timers:reprioritize(id, time);
 end
 
 -- Add relative timer
-local function addtimer(timeout, f)
-	return at(gettime() + timeout, f);
+local function addtimer(timeout, f, param)
+	local time = monotonic() + timeout;
+	if param ~= nil then
+		local timer_callback = f
+		function f(current_time, timer_id)
+			local t = timer_callback(current_time, timer_id, param)
+			return t;
+		end
+	end
+	local id = timers:insert(f, time);
+	return id;
 end
 
 -- Run callbacks of expired timers
 -- Return time until next timeout
 local function runtimers(next_delay, min_wait)
 	-- Any timers at all?
-	local now = gettime();
+	local elapsed = monotonic();
+	local now = realtime();
 	local peek = timers:peek();
 	local readd;
 	while peek do
 
-		if peek > now then
+		if peek > elapsed then
 			break;
 		end
 
 		local _, timer, id = timers:pop();
-		local ok, ret = pcall(timer[2], now);
-		if ok and type(ret) == "number"  then
-			local next_time = now+ret;
-			timer[1] = next_time;
+		local ok, ret = xpcall(timer, traceback, now, id);
+		if ok and type(ret) == "number" and not closedtimers[id] then
+			local next_time = elapsed+ret;
 			-- Delay insertion of timers to be re-added
 			-- so they don't get called again this tick
 			if readd then
-				readd[id] = timer;
+				readd[id] = { timer, next_time };
 			else
-				readd = { [id] = timer };
+				readd = { [id] = { timer, next_time } };
 			end
+		elseif not ok then
+			log("error", "Error in timer: %s", ret);
 		end
 
 		peek = timers:peek();
@@ -126,15 +164,19 @@
 
 	if readd then
 		for id, timer in pairs(readd) do
-			timers:insert(timer, timer[1], id);
+			timers:insert(timer[1], timer[2], id);
 		end
 		peek = timers:peek();
 	end
 
+	if next(closedtimers) ~= nil then
+		closedtimers = {};
+	end
+
 	if peek == nil then
 		return next_delay;
 	else
-		next_delay = peek - now;
+		next_delay = peek - elapsed;
 	end
 
 	if next_delay < min_wait then
@@ -157,6 +199,22 @@
 	return ("FD %d"):format(self:getfd());
 end
 
+interface.log = log;
+function interface:debug(msg, ...)
+	self.log("debug", msg, ...);
+end
+
+interface.noise = interface.debug;
+function interface:noise(msg, ...)
+	if cfg.verbose then
+		return self:debug(msg, ...);
+	end
+end
+
+function interface:error(msg, ...)
+	self.log("error", msg, ...);
+end
+
 -- Replace the listener and tell the old one
 function interface:setlistener(listeners, data)
 	self:on("detach");
@@ -167,21 +225,36 @@
 -- Call a listener callback
 function interface:on(what, ...)
 	if not self.listeners then
-		log("error", "%s has no listeners", self);
+		self:error("Interface is missing listener callbacks");
 		return;
 	end
 	local listener = self.listeners["on"..what];
 	if not listener then
-		-- log("debug", "Missing listener 'on%s'", what); -- uncomment for development and debugging
+		self:noise("Missing listener 'on%s'", what); -- uncomment for development and debugging
 		return;
 	end
-	local ok, err = pcall(listener, self, ...);
+	if not cfg.protect_listeners then
+		return listener(self, ...);
+	end
+	local onerror = self.listeners.onerror or traceback;
+	local ok, err = xpcall(listener, onerror, self, ...);
 	if not ok then
-		log("error", "Error calling on%s: %s", what, err);
+		if cfg.fatal_errors then
+			self:error("Closing due to error calling on%s: %s", what, err);
+			self:destroy();
+		else
+			self:error("Error calling on%s: %s", what, err);
+		end
+		return nil, err;
 	end
 	return err;
 end
 
+-- Allow this one to be overridden
+function interface:onincoming(...)
+	return self:on("incoming", ...);
+end
+
 -- Return the file descriptor number
 function interface:getfd()
 	if self.conn then
@@ -201,20 +274,24 @@
 
 -- Get a port number, doesn't matter which
 function interface:port()
-	return self.sockport or self.peerport;
+	return self.peerport or self.sockport;
 end
 
--- Get local port number
+-- Client-side port (usually a random high port)
 function interface:clientport()
-	return self.sockport;
+	if self._server then
+		return self.peerport;
+	else
+		return self.sockport;
+	end
 end
 
--- Get remote port
+-- Get port on the server
 function interface:serverport()
-	if self.sockport then
+	if self._server then
 		return self.sockport;
-	elseif self._server then
-		self._server:port();
+	else
+		return self.peerport;
 	end
 end
 
@@ -229,28 +306,36 @@
 
 function interface:setoption(k, v)
 	-- LuaSec doesn't expose setoption :(
-	if self.conn.setoption then
-		self.conn:setoption(k, v);
+	local ok, ret, err = pcall(self.conn.setoption, self.conn, k, v);
+	if not ok then
+		self:noise("Setting option %q = %q failed: %s", k, v, ret);
+		return ok, ret;
+	elseif not ret then
+		self:noise("Setting option %q = %q failed: %s", k, v, err);
+		return ret, err;
 	end
+	return ret;
 end
 
 -- Timeout for detecting dead or idle sockets
 function interface:setreadtimeout(t)
 	if t == false then
 		if self._readtimeout then
-			self._readtimeout:close();
+			closetimer(self._readtimeout);
 			self._readtimeout = nil;
 		end
 		return
 	end
 	t = t or cfg.read_timeout;
 	if self._readtimeout then
-		self._readtimeout:reschedule(gettime() + t);
+		reschedule(self._readtimeout, t);
 	else
 		self._readtimeout = addtimer(t, function ()
 			if self:on("readtimeout") then
+				self:noise("Read timeout handled");
 				return cfg.read_timeout;
 			else
+				self:debug("Read timeout not handled, disconnecting");
 				self:on("disconnect", "read timeout");
 				self:destroy();
 			end
@@ -262,17 +347,18 @@
 function interface:setwritetimeout(t)
 	if t == false then
 		if self._writetimeout then
-			self._writetimeout:close();
+			closetimer(self._writetimeout);
 			self._writetimeout = nil;
 		end
 		return
 	end
 	t = t or cfg.send_timeout;
 	if self._writetimeout then
-		self._writetimeout:reschedule(gettime() + t);
+		reschedule(self._writetimeout, t);
 	else
 		self._writetimeout = addtimer(t, function ()
-			self:on("disconnect", "write timeout");
+			self:noise("Write timeout");
+			self:on("disconnect", self._connected and "write timeout" or "connection timeout");
 			self:destroy();
 		end);
 	end
@@ -288,15 +374,15 @@
 	local ok, err, errno = poll:add(fd, r, w);
 	if not ok then
 		if errno == EEXIST then
-			log("debug", "%s already registered!", self);
+			self:debug("FD already registered in poller! (EEXIST)");
 			return self:set(r, w); -- So try to change its flags
 		end
-		log("error", "Could not register %s: %s(%d)", self, err, errno);
+		self:debug("Could not register in poller: %s(%d)", err, errno);
 		return ok, err;
 	end
 	self._wantread, self._wantwrite = r, w;
 	fds[fd] = self;
-	log("debug", "Watching %s", self);
+	self:noise("Registered in poller");
 	return true;
 end
 
@@ -309,7 +395,7 @@
 	if w == nil then w = self._wantwrite; end
 	local ok, err, errno = poll:set(fd, r, w);
 	if not ok then
-		log("error", "Could not update poller state %s: %s(%d)", self, err, errno);
+		self:debug("Could not update poller state: %s(%d)", err, errno);
 		return ok, err;
 	end
 	self._wantread, self._wantwrite = r, w;
@@ -326,12 +412,12 @@
 	end
 	local ok, err, errno = poll:del(fd);
 	if not ok and errno ~= ENOENT then
-		log("error", "Could not unregister %s: %s(%d)", self, err, errno);
+		self:debug("Could not unregister: %s(%d)", err, errno);
 		return ok, err;
 	end
 	self._wantread, self._wantwrite = nil, nil;
 	fds[fd] = nil;
-	log("debug", "Unwatched %s", self);
+	self:noise("Unregistered from poller");
 	return true;
 end
 
@@ -353,27 +439,44 @@
 	local data, err, partial = self.conn:receive(self.read_size or cfg.read_size);
 	if data then
 		self:onconnect();
-		self:on("incoming", data);
+		self:onincoming(data);
 	else
 		if err == "wantread" then
 			self:set(true, nil);
 			err = "timeout";
 		elseif err == "wantwrite" then
 			self:set(nil, true);
+			self:setwritetimeout();
 			err = "timeout";
+		elseif err == "timeout" and not self._connected then
+			err = "connection timeout";
 		end
 		if partial and partial ~= "" then
 			self:onconnect();
-			self:on("incoming", partial, err);
+			self:onincoming(partial, err);
 		end
-		if err ~= "timeout" then
+		if err == "closed" and self._connected then
+			self:debug("Connection closed by remote");
+			self:close(err);
+			return;
+		elseif err ~= "timeout" then
+			self:debug("Read error, closing (%s)", err);
 			self:on("disconnect", err);
-			self:destroy()
+			self:destroy();
 			return;
 		end
 	end
 	if not self.conn then return; end
-	if self._wantread and self.conn:dirty() then
+	if self._limit and (data or partial) then
+		local cost = self._limit * #(data or partial);
+		if cost > cfg.min_wait then
+			self:setreadtimeout(false);
+			self:pausefor(cost);
+			return;
+		end
+	end
+	if not self._wantread then return end
+	if self.conn:dirty() then
 		self:setreadtimeout(false);
 		self:pausefor(cfg.read_retry_delay);
 	else
@@ -383,34 +486,62 @@
 
 -- Called when socket is writable
 function interface:onwritable()
+	self._writing = true; -- prevent reentrant writes etc
 	self:onconnect();
-	if not self.conn then return; end -- could have been closed in onconnect
+	if not self.conn then return nil, "no-conn"; end -- could have been closed in onconnect
+	self:on("predrain");
 	local buffer = self.writebuffer;
-	local data = t_concat(buffer);
+	local data = buffer or "";
+	if type(buffer) == "table" then
+		if buffer[3] then
+			data = t_concat(data);
+		elseif buffer[2] then
+			data = buffer[1] .. buffer[2];
+		else
+			data = buffer[1] or "";
+		end
+	end
 	local ok, err, partial = self.conn:send(data);
+	self._writable = ok;
 	if ok then
 		self:set(nil, false);
-		for i = #buffer, 1, -1 do
-			buffer[i] = nil;
+		if cfg.keep_buffers and type(buffer) == "table" then
+			for i = #buffer, 1, -1 do
+				buffer[i] = nil;
+			end
+		else
+			self.writebuffer = nil;
 		end
+		self._writing = nil;
 		self:setwritetimeout(false);
 		self:ondrain(); -- Be aware of writes in ondrain
-		return;
+		return ok;
 	elseif partial then
-		buffer[1] = data:sub(partial+1);
-		for i = #buffer, 2, -1 do
-			buffer[i] = nil;
+		self:debug("Sent %d out of %d buffered bytes", partial, #data);
+		if cfg.keep_buffers and type(buffer) == "table" then
+			buffer[1] = data:sub(partial+1);
+			for i = #buffer, 2, -1 do
+				buffer[i] = nil;
+			end
+		else
+			self.writebuffer = data:sub(partial+1);
 		end
+		self:set(nil, true);
 		self:setwritetimeout();
 	end
+	self._writing = nil;
 	if err == "wantwrite" or err == "timeout" then
 		self:set(nil, true);
+		self:setwritetimeout();
 	elseif err == "wantread" then
 		self:set(true, nil);
+		self:setreadtimeout();
 	elseif err ~= "timeout" then
 		self:on("disconnect", err);
 		self:destroy();
+		return ok, err;
 	end
+	return true, err;
 end
 
 -- The write buffer has been successfully emptied
@@ -421,26 +552,40 @@
 -- Add data to write buffer and set flag for wanting to write
 function interface:write(data)
 	local buffer = self.writebuffer;
-	if buffer then
+	if type(buffer) == "table" then
 		t_insert(buffer, data);
-	else
-		self.writebuffer = { data };
+	elseif type(buffer) == "string" then
+		self:noise("Allocating buffer!")
+		self.writebuffer = { buffer, data };
+	elseif buffer == nil then
+		self.writebuffer = data;
 	end
-	self:setwritetimeout();
-	self:set(nil, true);
+	if not self._write_lock and not self._writing then
+		if self._writable and cfg.opportunistic_writes and not self._opportunistic_write then
+			self._opportunistic_write = true;
+			local ret, err = self:onwritable();
+			self._opportunistic_write = nil;
+			return ret, err;
+		end
+		self:setwritetimeout();
+		self:set(nil, true);
+	end
 	return #data;
 end
 interface.send = interface.write;
 
 -- Close, possibly after writing is done
 function interface:close()
-	if self.writebuffer and self.writebuffer[1] then
+	if self._connected and self.writebuffer and (self.writebuffer[1] or type(self.writebuffer) == "string") then
+		self._connected = false;
 		self:set(false, true); -- Flush final buffer contents
+		self:setreadtimeout(false);
+		self:setwritetimeout();
 		self.write, self.send = noop, noop; -- No more writing
-		log("debug", "Close %s after writing", self);
+		self:debug("Close after writing remaining buffered data");
 		self.ondrain = interface.close;
 	else
-		log("debug", "Close %s now", self);
+		self:debug("Closing now");
 		self.write, self.send = noop, noop;
 		self.close = noop;
 		self:on("disconnect");
@@ -465,70 +610,109 @@
 	return self._tls;
 end
 
+function interface:set_sslctx(sslctx)
+	self._sslctx = sslctx;
+end
+
 function interface:starttls(tls_ctx)
 	if tls_ctx then self.tls_ctx = tls_ctx; end
 	self.starttls = false;
-	if self.writebuffer and self.writebuffer[1] then
-		log("debug", "Start TLS on %s after write", self);
+	if self.writebuffer and (self.writebuffer[1] or type(self.writebuffer) == "string") then
+		self:debug("Start TLS after write");
 		self.ondrain = interface.starttls;
 		self:set(nil, true); -- make sure wantwrite is set
 	else
 		if self.ondrain == interface.starttls then
 			self.ondrain = nil;
 		end
-		self.onwritable = interface.tlshandskake;
-		self.onreadable = interface.tlshandskake;
+		self.onwritable = interface.inittls;
+		self.onreadable = interface.inittls;
 		self:set(true, true);
-		log("debug", "Prepare to start TLS on %s", self);
+		self:setreadtimeout(false);
+		self:setwritetimeout(cfg.ssl_handshake_timeout);
+		self:debug("Prepared to start TLS");
 	end
 end
 
-function interface:tlshandskake()
-	self:setwritetimeout(false);
-	self:setreadtimeout(false);
-	if not self._tls then
-		self._tls = true;
-		log("debug", "Start TLS on %s now", self);
-		self:del();
-		local ok, conn, err = pcall(luasec.wrap, self.conn, self.tls_ctx);
-		if not ok then
-			conn, err = ok, conn;
-			log("error", "Failed to initialize TLS: %s", err);
+function interface:inittls(tls_ctx, now)
+	if self._tls then return end
+	if tls_ctx then self.tls_ctx = tls_ctx; end
+	self._tls = true;
+	self.starttls = false;
+	self:debug("Starting TLS now");
+	self:updatenames(); -- Can't getpeer/sockname after wrap()
+	local ok, conn, err = pcall(luasec.wrap, self.conn, self.tls_ctx);
+	if not ok then
+		conn, err = ok, conn;
+		self:debug("Failed to initialize TLS: %s", err);
+	end
+	if not conn then
+		self:on("disconnect", err);
+		self:destroy();
+		return conn, err;
+	end
+	conn:settimeout(0);
+	self.conn = conn;
+	if conn.sni then
+		if self.servername then
+			conn:sni(self.servername);
+		elseif self._server and type(self._server.hosts) == "table" and next(self._server.hosts) ~= nil then
+			conn:sni(self._server.hosts, true);
 		end
-		if not conn then
-			self:on("disconnect", err);
-			self:destroy();
-			return conn, err;
+	end
+	if self.extra and self.extra.tlsa and conn.settlsa then
+		-- TODO Error handling
+		if not conn:setdane(self.servername or self.extra.dane_hostname) then
+			self:debug("Could not enable DANE on connection");
+		else
+			self:debug("Enabling DANE with %d TLSA records", #self.extra.tlsa);
+			self:noise("DANE hostname is %q", self.servername or self.extra.dane_hostname);
+			for _, tlsa in ipairs(self.extra.tlsa) do
+				self:noise("TLSA: %q", tlsa);
+				conn:settlsa(tlsa.use, tlsa.select, tlsa.match, tlsa.data);
+			end
 		end
-		conn:settimeout(0);
-		self.conn = conn;
-		if conn.sni and self.servername then
-			conn:sni(self.servername);
-		end
-		self:on("starttls");
-		self.ondrain = nil;
-		self.onwritable = interface.tlshandskake;
-		self.onreadable = interface.tlshandskake;
-		return self:init();
+	end
+	self:on("starttls");
+	self.ondrain = nil;
+	self.onwritable = interface.tlshandshake;
+	self.onreadable = interface.tlshandshake;
+	if now then
+		return self:tlshandshake()
 	end
+	self:setreadtimeout(false);
+	self:setwritetimeout(cfg.ssl_handshake_timeout);
+	self:set(true, true);
+end
+
+function interface:tlshandshake()
+	self:setreadtimeout(false);
+	self:noise("Continuing TLS handshake");
 	local ok, err = self.conn:dohandshake();
 	if ok then
-		log("debug", "TLS handshake on %s complete", self);
+		local info = self.conn.info and self.conn:info();
+		if type(info) == "table" then
+			self:debug("TLS handshake complete (%s with %s)", info.protocol, info.cipher);
+		else
+			self:debug("TLS handshake complete");
+		end
+		self:setwritetimeout(false);
 		self.onwritable = nil;
 		self.onreadable = nil;
 		self:on("status", "ssl-handshake-complete");
-		self:setwritetimeout();
 		self:set(true, true);
+		self:onconnect();
+		self:onreadable();
 	elseif err == "wantread" then
-		log("debug", "TLS handshake on %s to wait until readable", self);
+		self:noise("TLS handshake to wait until readable");
 		self:set(true, false);
-		self:setreadtimeout(cfg.ssl_handshake_timeout);
+		self:setwritetimeout(cfg.ssl_handshake_timeout);
 	elseif err == "wantwrite" then
-		log("debug", "TLS handshake on %s to wait until writable", self);
+		self:noise("TLS handshake to wait until writable");
 		self:set(false, true);
 		self:setwritetimeout(cfg.ssl_handshake_timeout);
 	else
-		log("debug", "TLS handshake error on %s: %s", self, err);
+		self:debug("TLS handshake error: %s", err);
 		self:on("disconnect", err);
 		self:destroy();
 	end
@@ -536,15 +720,18 @@
 
 local function wrapsocket(client, server, read_size, listeners, tls_ctx, extra) -- luasocket object -> interface object
 	client:settimeout(0);
+	local conn_id = ("conn%s"):format(new_id());
 	local conn = setmetatable({
 		conn = client;
 		_server = server;
-		created = gettime();
+		created = realtime();
 		listeners = listeners;
 		read_size = read_size or (server and server.read_size);
-		writebuffer = {};
+		writebuffer = nil;
 		tls_ctx = tls_ctx or (server and server.tls_ctx);
 		tls_direct = server and server.tls_direct;
+		id = conn_id;
+		log = logger.init(conn_id);
 		extra = extra;
 	}, interface_mt);
 
@@ -561,12 +748,12 @@
 function interface:updatenames()
 	local conn = self.conn;
 	local ok, peername, peerport = pcall(conn.getpeername, conn);
-	if ok then
-		self.peername, self.peerport = peername, peerport;
+	if ok and peername then
+		self.peername, self.peerport = peername, peerport or 0;
 	end
 	local ok, sockname, sockport = pcall(conn.getsockname, conn);
-	if ok then
-		self.sockname, self.sockport = sockname, sockport;
+	if ok and sockname then
+		self.sockname, self.sockport = sockname, sockport or 0;
 	end
 end
 
@@ -575,74 +762,147 @@
 function interface:onacceptable()
 	local conn, err = self.conn:accept();
 	if not conn then
-		log("debug", "Error accepting new client: %s, server will be paused for %ds", err, cfg.accept_retry_interval);
+		self:debug("Error accepting new client: %s, server will be paused for %ds", err, cfg.accept_retry_interval);
 		self:pausefor(cfg.accept_retry_interval);
 		return;
 	end
 	local client = wrapsocket(conn, self, nil, self.listeners);
-	log("debug", "New connection %s", tostring(client));
-	client:init();
+	client:debug("New connection %s on server %s", client, self);
+	client:defaultoptions();
+	client._writable = cfg.opportunistic_writes;
 	if self.tls_direct then
-		client:starttls(self.tls_ctx);
+		client:add(true, true);
+		client:inittls(self.tls_ctx, true);
+	else
+		client:add(true, false);
+		client:onconnect();
+		client:onreadable();
 	end
 end
 
--- Initialization
+-- Initialization for outgoing connections
 function interface:init()
-	self:setwritetimeout();
+	self:setwritetimeout(cfg.connect_timeout);
+	self:defaultoptions();
 	return self:add(true, true);
 end
 
+function interface:defaultoptions()
+	if cfg.nagle == false then
+		self:setoption("tcp-nodelay", true);
+	end
+	if cfg.tcp_keepalive then
+		self:setoption("keepalive", true);
+		if type(cfg.tcp_keepalive) == "number" then
+			self:setoption("tcp-keepidle", cfg.tcp_keepalive);
+		end
+	end
+end
+
 function interface:pause()
+	self:noise("Pause reading");
+	self:setreadtimeout(false);
 	return self:set(false);
 end
 
 function interface:resume()
+	self:noise("Resume reading");
+	self:setreadtimeout();
 	return self:set(true);
 end
 
 -- Pause connection for some time
 function interface:pausefor(t)
+	self:noise("Pause for %fs", t);
 	if self._pausefor then
-		self._pausefor:close();
+		closetimer(self._pausefor);
+		self._pausefor = nil;
 	end
 	if t == false then return; end
 	self:set(false);
 	self._pausefor = addtimer(t, function ()
 		self._pausefor = nil;
 		self:set(true);
+		self:noise("Resuming after pause");
 		if self.conn and self.conn:dirty() then
+			self:noise("Have buffered incoming data to process");
 			self:onreadable();
 		end
 	end);
 end
 
+function interface:setlimit(Bps)
+	if Bps > 0 then
+		self._limit = 1/Bps;
+	else
+		self._limit = nil;
+	end
+end
+
+function interface:pause_writes()
+	if self._write_lock then
+		return
+	end
+	self:noise("Pause writes");
+	self._write_lock = true;
+	self:setwritetimeout(false);
+	self:set(nil, false);
+end
+
+function interface:resume_writes()
+	if not self._write_lock then
+		return
+	end
+	self:noise("Resume writes");
+	self._write_lock = nil;
+	if self.writebuffer and (self.writebuffer[1] or type(self.writebuffer) == "string") then
+		self:setwritetimeout();
+		self:set(nil, true);
+	end
+end
+
 -- Connected!
 function interface:onconnect()
-	if self.conn and not self.peername and self.conn.getpeername then
-		self.peername, self.peerport = self.conn:getpeername();
-	end
+	self._connected = true;
+	self:updatenames();
+	self:debug("Connected (%s)", self);
 	self.onconnect = noop;
 	self:on("connect");
 end
 
-local function addserver(addr, port, listeners, read_size, tls_ctx)
+local function wrapserver(conn, addr, port, listeners, config)
+	local server = setmetatable({
+		conn = conn;
+		created = realtime();
+		listeners = listeners;
+		read_size = config and config.read_size;
+		onreadable = interface.onacceptable;
+		tls_ctx = config and config.tls_ctx;
+		tls_direct = config and config.tls_direct;
+		hosts = config and config.sni_hosts;
+		sockname = addr;
+		sockport = port;
+		log = logger.init(("serv%s"):format(new_id()));
+	}, interface_mt);
+	server:debug("Server %s created", server);
+	server:add(true, false);
+	return server;
+end
+
+local function listen(addr, port, listeners, config)
 	local conn, err = socket.bind(addr, port, cfg.tcp_backlog);
 	if not conn then return conn, err; end
 	conn:settimeout(0);
-	local server = setmetatable({
-		conn = conn;
-		created = gettime();
-		listeners = listeners;
+	return wrapserver(conn, addr, port, listeners, config);
+end
+
+-- COMPAT
+local function addserver(addr, port, listeners, read_size, tls_ctx)
+	return listen(addr, port, listeners, {
 		read_size = read_size;
-		onreadable = interface.onacceptable;
 		tls_ctx = tls_ctx;
 		tls_direct = tls_ctx and true or false;
-		sockname = addr;
-		sockport = port;
-	}, interface_mt);
-	server:add(true, false);
-	return server;
+	});
 end
 
 -- COMPAT
@@ -678,13 +938,19 @@
 		return nil, "invalid socket type";
 	end
 	local conn, err = create();
+	if not conn then return conn, err; end
 	local ok, err = conn:settimeout(0);
 	if not ok then return ok, err; end
 	local ok, err = conn:setpeername(addr, port);
 	if not ok and err ~= "timeout" then return ok, err; end
 	local client = wrapsocket(conn, nil, read_size, listeners, tls_ctx, extra)
 	local ok, err = client:init();
+	if not client.peername then
+		-- otherwise not set until connected
+		client.peername, client.peerport = addr, port;
+	end
 	if not ok then return ok, err; end
+	client:debug("Client %s created", client);
 	if tls_ctx then
 		client:starttls(tls_ctx);
 	end
@@ -706,23 +972,23 @@
 		end;
 		-- Otherwise it'll need to be something LuaSocket-compatible
 	end
+	conn.id = new_id();
+	conn.log = logger.init(("fdwatch%s"):format(conn.id));
 	conn:add(onreadable, onwritable);
 	return conn;
 end;
 
 -- Dump all data from one connection into another
-local function link(from, to)
-	from.listeners = setmetatable({
-		onincoming = function (_, data)
-			from:pause();
-			to:write(data);
-		end,
-	}, {__index=from.listeners});
-	to.listeners = setmetatable({
-		ondrain = function ()
-			from:resume();
-		end,
-	}, {__index=to.listeners});
+local function link(from, to, read_size)
+	from:debug("Linking to %s", to.id);
+	function from:onincoming(data)
+		self:pause();
+		to:write(data);
+	end
+	function to:ondrain() -- luacheck: ignore 212/self
+		from:resume();
+	end
+	from:set_mode(read_size);
 	from:set(true, nil);
 	to:set(nil, true);
 end
@@ -798,11 +1064,21 @@
 	addserver = addserver;
 	addclient = addclient;
 	add_task = addtimer;
-	at = at;
+	timer = {
+		-- API-compatible with util.timer
+		add_task = addtimer;
+		stop = closetimer;
+		reschedule = reschedule;
+		to_absolute_time = function (t)
+			return t-monotonic()+realtime();
+		end;
+	};
+	listen = listen;
 	loop = loop;
 	closeall = closeall;
 	setquitting = setquitting;
 	wrapclient = wrapclient;
+	wrapserver = wrapserver;
 	watchfd = watchfd;
 	link = link;
 	set_config = function (newconfig)
@@ -812,6 +1088,7 @@
 	-- libevent emulation
 	event = { EV_READ = "r", EV_WRITE = "w", EV_READWRITE = "rw", EV_LEAVE = -1 };
 	addevent = function (fd, mode, callback)
+		log("warn", "Using deprecated libevent emulation, please update code to use watchfd API instead");
 		local function onevent(self)
 			local ret = self:callback();
 			if ret == -1 then
@@ -831,6 +1108,8 @@
 				fds[fd] = nil;
 			end;
 		}, interface_mt);
+		conn.id = conn:getfd();
+		conn.log = logger.init(("fdwatch%d"):format(conn.id));
 		local ok, err = conn:add(mode == "r" or mode == "rw", mode == "w" or mode == "rw");
 		if not ok then return ok, err; end
 		return conn;
--- a/net/server_event.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/server_event.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -165,8 +165,12 @@
 		return false
 	end
 
-	if self.conn.sni and self.servername then
-		self.conn:sni(self.servername);
+	if self.conn.sni then
+		if self.servername then
+			self.conn:sni(self.servername);
+		elseif self._server and type(self._server.hosts) == "table" and next(self._server.hosts) ~= nil then
+			self.conn:sni(self._server.hosts, true);
+		end
 	end
 
 	self.conn:settimeout( 0 )  -- set non blocking
@@ -258,6 +262,7 @@
 
 --TODO: Deprecate
 function interface_mt:lock_read(switch)
+	log("warn", ":lock_read is deprecated, use :pause() and :resume()");
 	if switch then
 		return self:pause();
 	else
@@ -277,6 +282,19 @@
 	end
 end
 
+function interface_mt:pause_writes()
+	return self:_lock(self.nointerface, self.noreading, true);
+end
+
+function interface_mt:resume_writes()
+	self:_lock(self.nointerface, self.noreading, false);
+	if self.writecallback and not self.eventwrite then
+		self.eventwrite = addevent( base, self.conn, EV_WRITE, self.writecallback, cfg.WRITE_TIMEOUT );  -- register callback
+		return true;
+	end
+end
+
+
 function interface_mt:counter(c)
 	if c then
 		self._connections = self._connections + c
@@ -286,7 +304,7 @@
 
 -- Public methods
 function interface_mt:write(data)
-	if self.nowriting then return nil, "locked" end
+	if self.nointerface then return nil, "locked"; end
 	--vdebug( "try to send data to client, id/data:", self.id, data )
 	data = tostring( data )
 	local len = #data
@@ -298,7 +316,7 @@
 	end
 	t_insert(self.writebuffer, data) -- new buffer
 	self.writebufferlen = total
-	if not self.eventwrite then  -- register new write event
+	if not self.eventwrite and not self.nowriting  then  -- register new write event
 		--vdebug( "register new write event" )
 		self.eventwrite = addevent( base, self.conn, EV_WRITE, self.writecallback, cfg.WRITE_TIMEOUT )
 	end
@@ -431,6 +449,7 @@
 	self.onstatus = listener.onstatus;
 	self.ondetach = listener.ondetach;
 	self.onattach = listener.onattach;
+	self.onpredrain = listener.onpredrain;
 	self.ondrain = listener.ondrain;
 	self:onattach(data);
 end
@@ -445,10 +464,8 @@
 function interface_mt:ontimeout()
 end
 function interface_mt:onreadtimeout()
-	self.fatalerror = "timeout during receiving"
-	debug( "connection failed:", self.fatalerror )
-	self:_close()
-	self.eventread = nil
+end
+function interface_mt:onpredrain()
 end
 function interface_mt:ondrain()
 end
@@ -476,6 +493,7 @@
 		onincoming = listener.onincoming;  -- will be called when client sends data
 		ontimeout = listener.ontimeout; -- called when fatal socket timeout occurs
 		onreadtimeout = listener.onreadtimeout; -- called when socket inactivity timeout occurs
+		onpredrain = listener.onpredrain; -- called before writes
 		ondrain = listener.ondrain; -- called when writebuffer is empty
 		ondetach = listener.ondetach; -- called when disassociating this listener from this connection
 		onstatus = listener.onstatus; -- called for status changes (e.g. of SSL/TLS)
@@ -522,10 +540,11 @@
 					--vdebug( "tried to read in writecallback, result:", ret )
 				end
 				if interface.eventwritetimeout then  -- luasec only
-					interface.eventwritetimeout:close( )  -- first we have to close timeout event which where regged after a wantread error
+					interface.eventwritetimeout:close( )  -- first we have to close timeout event which where registered after a wantread error
 					interface.eventwritetimeout = false
 				end
 			end
+			interface:onpredrain();
 			interface.writebuffer = { t_concat(interface.writebuffer) }
 			local succ, err, byte = interface.conn:send( interface.writebuffer[1], 1, interface.writebufferlen )
 			--vdebug( "write data:", interface.writebuffer, "error:", err, "part:", byte )
@@ -588,7 +607,7 @@
 			return -1 -- took too long to get some data from client -> disconnect
 		end
 		if interface._usingssl then  -- handle luasec
-			if interface.eventwritetimeout then  -- ok, in the past writecallback was regged
+			if interface.eventwritetimeout then  -- ok, in the past writecallback was registered
 				local ret = interface.writecallback( )  -- call it
 				--vdebug( "tried to write in readcallback, result:", tostring(ret) )
 			end
@@ -642,7 +661,7 @@
 	return interface
 end
 
-local function handleserver( server, addr, port, pattern, listener, sslctx )  -- creates an server interface
+local function handleserver( server, addr, port, pattern, listener, sslctx, startssl )  -- creates a server interface
 	debug "creating server interface..."
 	local interface = {
 		_connections = 0;
@@ -658,6 +677,7 @@
 
 		_ip = addr, _port = port, _pattern = pattern,
 		_sslctx = sslctx;
+		hosts = {};
 	}
 	interface.id = tostring(interface):match("%x+$");
 	interface.readcallback = function( event )  -- server handler, called on incoming connections
@@ -677,6 +697,7 @@
 			end
 		end
 		--vdebug("max connection check ok, accepting...")
+		-- luacheck: ignore 231/err
 		local client, err = server:accept()    -- try to accept; TODO: check err
 		while client do
 			if interface._connections >= cfg.MAX_CONNECTIONS then
@@ -688,7 +709,7 @@
 			interface._connections = interface._connections + 1  -- increase connection count
 			local clientinterface = handleclient( client, client_ip, client_port, interface, pattern, listener, sslctx )
 			--vdebug( "client id:", clientinterface, "startssl:", startssl )
-			if has_luasec and sslctx then
+			if has_luasec and startssl then
 				clientinterface:starttls(sslctx, true)
 			else
 				clientinterface:_start_session( true )
@@ -707,9 +728,9 @@
 	return interface
 end
 
-local function addserver( addr, port, listener, pattern, sslctx, startssl )  -- TODO: check arguments
-	--vdebug( "creating new tcp server with following parameters:", addr or "nil", port or "nil", sslctx or "nil", startssl or "nil")
-	if sslctx and not has_luasec then
+local function listen(addr, port, listener, config)
+	config = config or {}
+	if config.sslctx and not has_luasec then
 		debug "fatal error: luasec not found"
 		return nil, "luasec not found"
 	end
@@ -718,11 +739,20 @@
 		debug( "creating server socket on "..addr.." port "..port.." failed:", err )
 		return nil, err
 	end
-	local interface = handleserver( server, addr, port, pattern, listener, sslctx, startssl )  -- new server handler
+	local interface = handleserver( server, addr, port, config.read_size, listener, config.tls_ctx, config.tls_direct)  -- new server handler
 	debug( "new server created with id:", tostring(interface))
 	return interface
 end
 
+local function addserver( addr, port, listener, pattern, sslctx )  -- TODO: check arguments
+	--vdebug( "creating new tcp server with following parameters:", addr or "nil", port or "nil", sslctx or "nil", startssl or "nil")
+	return listen( addr, port, listener, {
+		read_size = pattern,
+		tls_ctx = sslctx,
+		tls_direct = not not sslctx,
+	});
+end
+
 local function wrapclient( client, ip, port, listeners, pattern, sslctx, extra )
 	local interface = handleclient( client, ip, port, nil, pattern, listeners, sslctx, extra )
 	interface:_start_connection(sslctx)
@@ -756,6 +786,7 @@
 	client:settimeout( 0 )  -- set nonblocking
 	local res, err = client:setpeername( addr, serverport )  -- connect
 	if res or ( err == "timeout" ) then
+		-- luacheck: ignore 211/port
 		local ip, port = client:getsockname( )
 		local interface = wrapclient( client, ip, serverport, listener, pattern, sslctx, extra )
 		debug( "new connection id:", interface.id )
@@ -883,6 +914,7 @@
 	event_base = base,
 	addevent = newevent,
 	addserver = addserver,
+	listen = listen,
 	addclient = addclient,
 	wrapclient = wrapclient,
 	setquitting = setquitting,
--- a/net/server_select.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/server_select.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -68,6 +68,7 @@
 local closeall
 local addsocket
 local addserver
+local listen
 local addtimer
 local getserver
 local wrapserver
@@ -123,7 +124,7 @@
 
 _server = { } -- key = port, value = table; list of listening servers
 _readlist = { } -- array with sockets to read from
-_sendlist = { } -- arrary with sockets to write to
+_sendlist = { } -- array with sockets to write to
 _timerlist = { } -- array of timer functions
 _socketlist = { } -- key = socket, value = wrapped socket (handlers)
 _readtimes = { } -- key = handler, value = timestamp of last data reading
@@ -149,7 +150,7 @@
 _sendtimeout = 60000 -- allowed send idle time in secs
 _readtimeout = 14 * 60 -- allowed read idle time in secs
 
-local is_windows = package.config:sub(1,1) == "\\" -- check the directory separator, to detemine whether this is Windows
+local is_windows = package.config:sub(1,1) == "\\" -- check the directory separator, to determine whether this is Windows
 _maxfd = (is_windows and math.huge) or luasocket._SETSIZE or 1024 -- max fd number, limit to 1024 by default to prevent glibc buffer overflow, but not on Windows
 _maxselectlen = luasocket._SETSIZE or 1024 -- But this still applies on Windows
 
@@ -157,7 +158,7 @@
 
 ----------------------------------// PRIVATE //--
 
-wrapserver = function( listeners, socket, ip, serverport, pattern, sslctx ) -- this function wraps a server -- FIXME Make sure FD < _maxfd
+wrapserver = function( listeners, socket, ip, serverport, pattern, sslctx, ssldirect ) -- this function wraps a server -- FIXME Make sure FD < _maxfd
 
 	if socket:getfd() >= _maxfd then
 		out_error("server.lua: Disallowed FD number: "..socket:getfd())
@@ -183,6 +184,7 @@
 	handler.sslctx = function( )
 		return sslctx
 	end
+	handler.hosts = {} -- sni
 	handler.remove = function( )
 		connections = connections - 1
 		if handler then
@@ -244,13 +246,13 @@
 		local client, err = accept( socket )	-- try to accept
 		if client then
 			local ip, clientport = client:getpeername( )
-			local handler, client, err = wrapconnection( handler, listeners, client, ip, serverport, clientport, pattern, sslctx ) -- wrap new client socket
+			local handler, client, err = wrapconnection( handler, listeners, client, ip, serverport, clientport, pattern, sslctx, ssldirect ) -- wrap new client socket
 			if err then -- error while wrapping ssl socket
 				return false
 			end
 			connections = connections + 1
 			out_put( "server.lua: accepted new client connection from ", tostring(ip), ":", tostring(clientport), " to ", tostring(serverport))
-			if dispatch and not sslctx then -- SSL connections will notify onconnect when handshake completes
+			if dispatch and not ssldirect then -- SSL connections will notify onconnect when handshake completes
 				return dispatch( handler );
 			end
 			return;
@@ -264,7 +266,7 @@
 	return handler
 end
 
-wrapconnection = function( server, listeners, socket, ip, serverport, clientport, pattern, sslctx, extra ) -- this function wraps a client to a handler object
+wrapconnection = function( server, listeners, socket, ip, serverport, clientport, pattern, sslctx, ssldirect, extra ) -- this function wraps a client to a handler object
 
 	if socket:getfd() >= _maxfd then
 		out_error("server.lua: Disallowed FD number: "..socket:getfd()) -- PROTIP: Switch to libevent
@@ -287,9 +289,12 @@
 
 	local ssl
 
+	local pending
+
 	local dispatch = listeners.onincoming
 	local status = listeners.onstatus
 	local disconnect = listeners.ondisconnect
+	local predrain = listeners.onpredrain
 	local drain = listeners.ondrain
 	local onreadtimeout = listeners.onreadtimeout;
 	local detach = listeners.ondetach
@@ -334,6 +339,7 @@
 		dispatch = listeners.onincoming
 		disconnect = listeners.ondisconnect
 		status = listeners.onstatus
+		predrain = listeners.onpredrain
 		drain = listeners.ondrain
 		handler.onreadtimeout = listeners.onreadtimeout
 		detach = listeners.ondetach
@@ -341,6 +347,9 @@
 			listeners.onattach(self, data)
 		end
 	end
+	handler._setpending = function( )
+		pending = true
+	end
 	handler.getstats = function( )
 		return readtraffic, sendtraffic
 	end
@@ -377,7 +386,7 @@
 		_readlistlen = removesocket( _readlist, socket, _readlistlen )
 		_readtimes[ handler ] = nil
 		if bufferqueuelen ~= 0 then
-			handler.sendbuffer() -- Try now to send any outstanding data
+			handler:sendbuffer() -- Try now to send any outstanding data
 			if bufferqueuelen ~= 0 then -- Still not empty, so we'll try again later
 				if handler then
 					handler.write = nil -- ... but no further writing allowed
@@ -429,9 +438,8 @@
 		bufferlen = bufferlen + #data
 		if bufferlen > maxsendlen then
 			_closelist[ handler ] = "send buffer exceeded"	 -- cannot close the client at the moment, have to wait to the end of the cycle
-			handler.write = idfalse -- don't write anymore
 			return false
-		elseif socket and not _sendlist[ socket ] then
+		elseif not nosend and socket and not _sendlist[ socket ] then
 			_sendlistlen = addsocket(_sendlist, socket, _sendlistlen)
 		end
 		bufferqueuelen = bufferqueuelen + 1
@@ -461,49 +469,55 @@
 		maxreadlen = readlen or maxreadlen
 		return bufferlen, maxreadlen, maxsendlen
 	end
-	--TODO: Deprecate
 	handler.lock_read = function (self, switch)
+		out_error( "server.lua, lock_read() is deprecated, use pause() and resume()" )
 		if switch == true then
-			local tmp = _readlistlen
-			_readlistlen = removesocket( _readlist, socket, _readlistlen )
-			_readtimes[ handler ] = nil
-			if _readlistlen ~= tmp then
-				noread = true
-			end
+			return self:pause()
 		elseif switch == false then
-			if noread then
-				noread = false
-				_readlistlen = addsocket(_readlist, socket, _readlistlen)
-				_readtimes[ handler ] = _currenttime
-			end
+			return self:resume()
 		end
 		return noread
 	end
 	handler.pause = function (self)
-		return self:lock_read(true);
+		local tmp = _readlistlen
+		_readlistlen = removesocket( _readlist, socket, _readlistlen )
+		_readtimes[ handler ] = nil
+		if _readlistlen ~= tmp then
+			noread = true
+		end
+		return noread;
 	end
 	handler.resume = function (self)
-		return self:lock_read(false);
+		if noread then
+			noread = false
+			_readlistlen = addsocket(_readlist, socket, _readlistlen)
+			_readtimes[ handler ] = _currenttime
+		end
+		return noread;
 	end
 	handler.lock = function( self, switch )
-		handler.lock_read (switch)
+		out_error( "server.lua, lock() is deprecated" )
+		handler.lock_read (self, switch)
 		if switch == true then
-			handler.write = idfalse
-			local tmp = _sendlistlen
-			_sendlistlen = removesocket( _sendlist, socket, _sendlistlen )
-			_writetimes[ handler ] = nil
-			if _sendlistlen ~= tmp then
-				nosend = true
-			end
+			handler.pause_writes (self)
 		elseif switch == false then
-			handler.write = write
-			if nosend then
-				nosend = false
-				write( "" )
-			end
+			handler.resume_writes (self)
 		end
 		return noread, nosend
 	end
+	handler.pause_writes = function (self)
+		local tmp = _sendlistlen
+		_sendlistlen = removesocket( _sendlist, socket, _sendlistlen )
+		_writetimes[ handler ] = nil
+		nosend = true
+	end
+	handler.resume_writes = function (self)
+		nosend = false
+		if bufferlen > 0 and socket then
+			_sendlistlen = addsocket(_sendlist, socket, _sendlistlen)
+		end
+	end
+
 	local _readbuffer = function( ) -- this function reads data
 		local buffer, err, part = receive( socket, pattern )	-- receive buffer with "pattern"
 		if not err or (err == "wantread" or err == "timeout") then -- received something
@@ -518,6 +532,12 @@
 			_readtraffic = _readtraffic + count
 			_readtimes[ handler ] = _currenttime
 			--out_put( "server.lua: read data '", buffer:gsub("[^%w%p ]", "."), "', error: ", err )
+			if pending then -- connection established
+				pending = nil
+				if listeners.onconnect then
+					listeners.onconnect(handler)
+				end
+			end
 			return dispatch( handler, buffer, err )
 		else	-- connections was closed or fatal error
 			out_put( "server.lua: client ", tostring(ip), ":", tostring(clientport), " read error: ", tostring(err) )
@@ -528,6 +548,15 @@
 	local _sendbuffer = function( ) -- this function sends data
 		local succ, err, byte, buffer, count;
 		if socket then
+			if pending then
+				pending = nil
+				if listeners.onconnect then
+					listeners.onconnect(handler);
+				end
+			end
+			if predrain then
+				predrain(handler);
+			end
 			buffer = table_concat( bufferqueue, "", 1, bufferqueuelen )
 			succ, err, byte = send( socket, buffer, 1, bufferlen )
 			count = ( succ or byte or 0 ) * STAT_UNIT
@@ -604,7 +633,7 @@
 						coroutine_yield( ) -- handshake not finished
 					end
 				end
-				err = "ssl handshake error: " .. ( err or "handshake too long" );
+				err = ( err or "handshake too long" );
 				out_put( "server.lua: ", err );
 				_ = handler and handler:force_close(err)
 				return false, err -- handshake failed
@@ -624,13 +653,18 @@
 			out_put( "server.lua: attempting to start tls on " .. tostring( socket ) )
 			local oldsocket, err = socket
 			socket, err = ssl_wrap( socket, sslctx )	-- wrap socket
+
 			if not socket then
 				out_put( "server.lua: error while starting tls on client: ", tostring(err or "unknown error") )
 				return nil, err -- fatal error
 			end
 
-			if socket.sni and self.servername then
-				socket:sni(self.servername);
+			if socket.sni then
+				if self.servername then
+					socket:sni(self.servername);
+				elseif self._server and type(self._server.hosts) == "table" and next(self._server.hosts) ~= nil then
+					socket:sni(self.server().hosts, true);
+				end
 			end
 
 			socket:settimeout( 0 )
@@ -668,7 +702,7 @@
 	_socketlist[ socket ] = handler
 	_readlistlen = addsocket(_readlist, socket, _readlistlen)
 
-	if sslctx and has_luasec then
+	if sslctx and ssldirect and has_luasec then
 		out_put "server.lua: auto-starting ssl negotiation..."
 		handler.autostart_ssl = true;
 		local ok, err = handler:starttls(sslctx);
@@ -723,7 +757,7 @@
 	local sender_locked;
 	local _sendbuffer = receiver.sendbuffer;
 	function receiver.sendbuffer()
-		_sendbuffer();
+		_sendbuffer(receiver);
 		if sender_locked and receiver.bufferlen() < buffersize then
 			sender:lock_read(false); -- Unlock now
 			sender_locked = nil;
@@ -743,9 +777,13 @@
 
 ----------------------------------// PUBLIC //--
 
-addserver = function( addr, port, listeners, pattern, sslctx ) -- this function provides a way for other scripts to reg a server
+listen = function ( addr, port, listeners, config )
 	addr = addr or "*"
+	config = config or {}
 	local err
+	local sslctx = config.tls_ctx;
+	local ssldirect = config.tls_direct;
+	local pattern = config.read_size;
 	if type( listeners ) ~= "table" then
 		err = "invalid listener table"
 	elseif type ( addr ) ~= "string" then
@@ -766,7 +804,7 @@
 		out_error( "server.lua, [", addr, "]:", port, ": ", err )
 		return nil, err
 	end
-	local handler, err = wrapserver( listeners, server, addr, port, pattern, sslctx ) -- wrap new server socket
+	local handler, err = wrapserver( listeners, server, addr, port, pattern, sslctx, ssldirect ) -- wrap new server socket
 	if not handler then
 		server:close( )
 		return nil, err
@@ -779,6 +817,14 @@
 	return handler
 end
 
+addserver = function( addr, port, listeners, pattern, sslctx ) -- this function provides a way for other scripts to reg a server
+	return listen(addr, port, listeners, {
+		read_size = pattern;
+		tls_ctx = sslctx;
+		tls_direct = sslctx and true or false;
+	});
+end
+
 getserver = function ( addr, port )
 	return _server[ addr..":"..port ];
 end
@@ -921,7 +967,7 @@
 		for _, socket in ipairs( read ) do -- receive data
 			local handler = _socketlist[ socket ]
 			if handler then
-				handler.readbuffer( )
+				handler:readbuffer( )
 			else
 				closesocket( socket )
 				out_put "server.lua: found no handler and closed socket (readlist)" -- this can happen
@@ -930,7 +976,7 @@
 		for _, socket in ipairs( write ) do -- send data waiting in writequeues
 			local handler = _socketlist[ socket ]
 			if handler then
-				handler.sendbuffer( )
+				handler:sendbuffer( )
 			else
 				closesocket( socket )
 				out_put "server.lua: found no handler and closed socket (writelist)"	-- this should not happen
@@ -987,21 +1033,13 @@
 --// EXPERIMENTAL //--
 
 local wrapclient = function( socket, ip, serverport, listeners, pattern, sslctx, extra )
-	local handler, socket, err = wrapconnection( nil, listeners, socket, ip, serverport, "clientport", pattern, sslctx, extra)
+	local handler, socket, err = wrapconnection( nil, listeners, socket, ip, serverport, "clientport", pattern, sslctx, sslctx, extra)
 	if not handler then return nil, err end
 	_socketlist[ socket ] = handler
 	if not sslctx then
+		handler._setpending()
 		_readlistlen = addsocket(_readlist, socket, _readlistlen)
 		_sendlistlen = addsocket(_sendlist, socket, _sendlistlen)
-		if listeners.onconnect then
-			-- When socket is writeable, call onconnect
-			local _sendbuffer = handler.sendbuffer;
-			handler.sendbuffer = function ()
-				handler.sendbuffer = _sendbuffer;
-				listeners.onconnect(handler);
-				return _sendbuffer(); -- Send any queued outgoing data
-			end
-		end
 	end
 	return handler, socket
 end
@@ -1123,6 +1161,7 @@
 	stats = stats,
 	closeall = closeall,
 	addserver = addserver,
+	listen = listen,
 	getserver = getserver,
 	setlogger = setlogger,
 	getsettings = getsettings,
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/stun.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,320 @@
+local base64 = require "util.encodings".base64;
+local hashes = require "util.hashes";
+local net = require "util.net";
+local random = require "util.random";
+local struct = require "util.struct";
+local bit32 = require"util.bitcompat";
+local sxor = require"util.strbitop".sxor;
+local new_ip = require "util.ip".new_ip;
+
+--- Public helpers
+
+-- Following draft-uberti-behave-turn-rest-00, convert a 'secret' string
+-- into a username/password pair that can be used to auth to a TURN server
+local function get_user_pass_from_secret(secret, ttl, opt_username)
+	ttl = ttl or 86400;
+	local username;
+	if opt_username then
+		username = ("%d:%s"):format(os.time() + ttl, opt_username);
+	else
+		username = ("%d"):format(os.time() + ttl);
+	end
+	local password = base64.encode(hashes.hmac_sha1(secret, username));
+	return username, password, ttl;
+end
+
+-- Following RFC 8489 9.2, convert credentials to a HMAC key for signing
+local function get_long_term_auth_key(realm, username, password)
+	return hashes.md5(username..":"..realm..":"..password);
+end
+
+--- Packet building/parsing
+
+local packet_methods = {};
+local packet_mt = { __index = packet_methods };
+
+local magic_cookie = string.char(0x21, 0x12, 0xA4, 0x42);
+
+local function lookup_table(t)
+	local lookup = {};
+	for k, v in pairs(t) do
+		lookup[k] = v;
+		lookup[v] = k;
+	end
+	return lookup;
+end
+
+local methods = {
+	binding = 0x001;
+	-- TURN
+	allocate = 0x003;
+	refresh = 0x004;
+	send = 0x006;
+	data = 0x007;
+	["create-permission"] = 0x008;
+	["channel-bind"] = 0x009;
+};
+local method_lookup = lookup_table(methods);
+
+local classes = {
+	request = 0;
+	indication = 1;
+	success = 2;
+	error = 3;
+};
+local class_lookup = lookup_table(classes);
+
+local addr_families = { "IPv4", "IPv6" };
+local addr_family_lookup = lookup_table(addr_families);
+
+local attributes = {
+	["mapped-address"] = 0x0001;
+	["username"] = 0x0006;
+	["message-integrity"] = 0x0008;
+	["error-code"] = 0x0009;
+	["unknown-attributes"] = 0x000A;
+	["realm"] = 0x0014;
+	["nonce"] = 0x0015;
+	["xor-mapped-address"] = 0x0020;
+	["software"] = 0x8022;
+	["alternate-server"] = 0x8023;
+	["fingerprint"] = 0x8028;
+	["message-integrity-sha256"] = 0x001C;
+	["password-algorithm"] = 0x001D;
+	["userhash"] = 0x001E;
+	["password-algorithms"] = 0x8002;
+	["alternate-domains"] = 0x8003;
+
+	-- TURN
+	["requested-transport"] = 0x0019;
+	["xor-peer-address"] = 0x0012;
+	["data"] = 0x0013;
+	["xor-relayed-address"] = 0x0016;
+};
+local attribute_lookup = lookup_table(attributes);
+
+function packet_methods:serialize_header(length)
+	assert(#self.transaction_id == 12, "invalid transaction id length");
+	local header = struct.pack(">I2I2",
+		self.type,
+		length
+	)..magic_cookie..self.transaction_id;
+	return header;
+end
+
+function packet_methods:serialize()
+	local payload = table.concat(self.attributes);
+	return self:serialize_header(#payload)..payload;
+end
+
+function packet_methods:is_request()
+	return bit32.band(self.type, 0x0110) == 0x0000;
+end
+
+function packet_methods:is_indication()
+	return bit32.band(self.type, 0x0110) == 0x0010;
+end
+
+function packet_methods:is_success_resp()
+	return bit32.band(self.type, 0x0110) == 0x0100;
+end
+
+function packet_methods:is_err_resp()
+	return bit32.band(self.type, 0x0110) == 0x0110;
+end
+
+function packet_methods:get_method()
+	local method = bit32.bor(
+		bit32.rshift(bit32.band(self.type, 0x3E00), 2),
+		bit32.rshift(bit32.band(self.type, 0x00E0), 1),
+		bit32.band(self.type, 0x000F)
+	);
+	return method, method_lookup[method];
+end
+
+function packet_methods:get_class()
+	local class = bit32.bor(
+		bit32.rshift(bit32.band(self.type, 0x0100), 7),
+		bit32.rshift(bit32.band(self.type, 0x0010), 4)
+	);
+	return class, class_lookup[class];
+end
+
+function packet_methods:set_type(method, class)
+	if type(method) == "string" then
+		method = assert(method_lookup[method:lower()], "unknown method: "..method);
+	end
+	if type(class) == "string" then
+		class = assert(classes[class], "unknown class: "..class);
+	end
+	self.type = bit32.bor(
+		bit32.lshift(bit32.band(method, 0x1F80), 2),
+		bit32.lshift(bit32.band(method, 0x0070), 1),
+		bit32.band(method, 0x000F),
+		bit32.lshift(bit32.band(class, 0x0002), 7),
+		bit32.lshift(bit32.band(class, 0x0001), 4)
+	);
+end
+
+local function _serialize_attribute(attr_type, value)
+	local len = #value;
+	local padding = string.rep("\0", (4 - len)%4);
+	return struct.pack(">I2I2",
+		attr_type, len
+	)..value..padding;
+end
+
+function packet_methods:add_attribute(attr_type, value)
+	if type(attr_type) == "string" then
+		attr_type = assert(attributes[attr_type], "unknown attribute: "..attr_type);
+	end
+	table.insert(self.attributes, _serialize_attribute(attr_type, value));
+end
+
+function packet_methods:deserialize(bytes)
+	local type, len, cookie = struct.unpack(">I2I2I4", bytes);
+	assert(#bytes == (len + 20), "incorrect packet length");
+	assert(cookie == 0x2112A442, "invalid magic cookie");
+	self.type = type;
+	self.transaction_id = bytes:sub(9, 20);
+	self.attributes = {};
+	local pos = 21;
+	while pos < #bytes do
+		local attr_hdr = bytes:sub(pos, pos+3);
+		assert(#attr_hdr == 4, "packet truncated in attribute header");
+		local attr_type, attr_len = struct.unpack(">I2I2", attr_hdr); --luacheck: ignore 211/attr_type
+		if attr_len == 0 then
+			table.insert(self.attributes, attr_hdr);
+			pos = pos + 20;
+		else
+			local data = bytes:sub(pos + 4, pos + 3 + attr_len);
+			assert(#data == attr_len, "packet truncated in attribute value");
+			table.insert(self.attributes, attr_hdr..data);
+			local n_padding = (4 - attr_len)%4;
+			pos = pos + 4 + attr_len + n_padding;
+		end
+	end
+	return self;
+end
+
+function packet_methods:get_attribute(attr_type, idx)
+	idx = math.max(idx or 1, 1);
+	if type(attr_type) == "string" then
+		attr_type = assert(attribute_lookup[attr_type:lower()], "unknown attribute: "..attr_type);
+	end
+	for _, attribute in ipairs(self.attributes) do
+		if struct.unpack(">I2", attribute) == attr_type then
+			if idx == 1 then
+				return attribute:sub(5);
+			else
+				idx = idx - 1;
+			end
+		end
+	end
+end
+
+function packet_methods:_unpack_address(data, xor)
+	local family, port = struct.unpack("x>BI2", data);
+	local addr = data:sub(5);
+	if xor then
+		port = bit32.bxor(port, 0x2112);
+		addr = sxor(addr, magic_cookie..self.transaction_id);
+	end
+	return {
+		family = addr_families[family] or "unknown";
+		port = port;
+		address = net.ntop(addr);
+	};
+end
+
+function packet_methods:_pack_address(family, addr, port, xor)
+	if xor then
+		port = bit32.bxor(port, 0x2112);
+		addr = sxor(addr, magic_cookie..self.transaction_id);
+	end
+	local family_port = struct.pack("x>BI2", family, port);
+	return family_port..addr
+end
+
+function packet_methods:get_mapped_address()
+	local data = self:get_attribute("mapped-address");
+	if not data then return; end
+	return self:_unpack_address(data, false);
+end
+
+function packet_methods:get_xor_mapped_address()
+	local data = self:get_attribute("xor-mapped-address");
+	if not data then return; end
+	return self:_unpack_address(data, true);
+end
+
+function packet_methods:add_xor_peer_address(address, port)
+	local parsed_ip = assert(new_ip(address));
+	local family = assert(addr_family_lookup[parsed_ip.proto], "Unknown IP address family: "..parsed_ip.proto);
+	self:add_attribute("xor-peer-address", self:_pack_address(family, parsed_ip.packed, port or 0, true));
+end
+
+function packet_methods:get_xor_relayed_address(idx)
+	local data = self:get_attribute("xor-relayed-address", idx);
+	if not data then return; end
+	return self:_unpack_address(data, true);
+end
+
+function packet_methods:get_xor_relayed_addresses()
+	return {
+		self:get_xor_relayed_address(1);
+		self:get_xor_relayed_address(2);
+	};
+end
+
+function packet_methods:add_message_integrity(key)
+	-- Add attribute with a dummy value so we can artificially increase
+	-- the packet 'length'
+	self:add_attribute("message-integrity", string.rep("\0", 20));
+	-- Get the packet data, minus the message-integrity attribute itself
+	local pkt = self:serialize():sub(1, -25);
+	local hash = hashes.hmac_sha1(key, pkt, false);
+	self.attributes[#self.attributes] = nil;
+	assert(#hash == 20, "invalid hash length");
+	self:add_attribute("message-integrity", hash);
+end
+
+do
+	local transports = {
+		udp = 0x11;
+	};
+	function packet_methods:add_requested_transport(transport)
+		local transport_code = transports[transport];
+		assert(transport_code, "unsupported transport: "..tostring(transport));
+		self:add_attribute("requested-transport", string.char(
+			transport_code, 0x00, 0x00, 0x00
+		));
+	end
+end
+
+function packet_methods:get_error()
+	local err_attr = self:get_attribute("error-code");
+	if not err_attr then
+		return nil;
+	end
+	local number = err_attr:byte(4);
+	local class = bit32.band(0x07, err_attr:byte(3));
+	local msg = err_attr:sub(5);
+	return (class*100)+number, msg;
+end
+
+local function new_packet(method, class)
+	local p = setmetatable({
+		transaction_id = random.bytes(12);
+		length = 0;
+		attributes = {};
+	}, packet_mt);
+	p:set_type(method or "binding", class or "request");
+	return p;
+end
+
+return {
+	new_packet = new_packet;
+	get_user_pass_from_secret = get_user_pass_from_secret;
+	get_long_term_auth_key = get_long_term_auth_key;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/unbound.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,226 @@
+-- libunbound based net.adns replacement for Prosody IM
+-- Copyright (C) 2013-2015 Kim Alvefur
+--
+-- This file is MIT licensed.
+--
+-- luacheck: ignore prosody
+
+local setmetatable = setmetatable;
+local tostring = tostring;
+local t_concat = table.concat;
+local s_format = string.format;
+local s_lower = string.lower;
+local s_upper = string.upper;
+local noop = function() end;
+
+local logger = require "util.logger";
+local log = logger.init("unbound");
+local net_server = require "net.server";
+local libunbound = require"lunbound";
+local promise = require"util.promise";
+local new_id = require "util.id".short;
+
+local gettime = require"socket".gettime;
+local dns_utils = require"util.dns";
+local classes, types, errors = dns_utils.classes, dns_utils.types, dns_utils.errors;
+local parsers = dns_utils.parsers;
+
+local builtin_defaults = { hoststxt = false }
+
+local function add_defaults(conf)
+	conf = conf or {};
+	for option, default in pairs(builtin_defaults) do
+		if conf[option] == nil then
+			conf[option] = default;
+		end
+	end
+	for option, default in pairs(libunbound.config) do
+		if conf[option] == nil then
+			conf[option] = default;
+		end
+	end
+	return conf;
+end
+
+local unbound_config;
+if prosody then
+	local config = require"core.configmanager";
+	unbound_config = add_defaults(config.get("*", "unbound"));
+	prosody.events.add_handler("config-reloaded", function()
+		unbound_config = add_defaults(config.get("*", "unbound"));
+	end);
+end
+-- Note: libunbound will default to using root hints if resolvconf is unset
+
+local function connect_server(unbound, server)
+	log("debug", "Setting up net.server event handling for %s", unbound);
+	return server.watchfd(unbound, function ()
+		log("debug", "Processing queries for %s", unbound);
+		unbound:process()
+	end);
+end
+
+local unbound, server_conn;
+
+local function initialize()
+	unbound = libunbound.new(unbound_config);
+	server_conn = connect_server(unbound, net_server);
+end
+if prosody then
+	prosody.events.add_handler("server-started", initialize);
+end
+
+local answer_mt = {
+	__tostring = function(self)
+		if self._string then return self._string end
+		local h = s_format("Status: %s", errors[self.status]);
+		if self.secure then
+			h = h .. ", Secure";
+		elseif self.bogus then
+			h = h .. s_format(", Bogus: %s", self.bogus);
+		end
+		local t = { h };
+		for i = 1, #self do
+			t[i+1]=self.qname.."\t"..classes[self.qclass].."\t"..types[self.qtype].."\t"..tostring(self[i]);
+		end
+		local _string = t_concat(t, "\n");
+		self._string = _string;
+		return _string;
+	end;
+};
+
+local waiting_queries = {};
+
+local function prep_answer(a)
+	if not a then return end
+	local status = errors[a.rcode];
+	local qclass = classes[a.qclass];
+	local qtype = types[a.qtype];
+	a.status, a.class, a.type = status, qclass, qtype;
+
+	local t = s_lower(qtype);
+	local rr_mt = { __index = a, __tostring = function(self) return tostring(self[t]) end };
+	local parser = parsers[qtype];
+	for i = 1, #a do
+		if a.bogus then
+			-- Discard bogus data
+			a[i] = nil;
+		else
+			a[i] = setmetatable({[t] = parser(a[i])}, rr_mt);
+		end
+	end
+	return setmetatable(a, answer_mt);
+end
+
+local function lookup(callback, qname, qtype, qclass)
+	if not unbound then initialize(); end
+	qtype = qtype and s_upper(qtype) or "A";
+	qclass = qclass and s_upper(qclass) or "IN";
+	local ntype, nclass = types[qtype], classes[qclass];
+	local startedat = gettime();
+	local ret;
+	local log_query = logger.init("unbound.query"..new_id());
+	local function callback_wrapper(a, err)
+		local gotdataat = gettime();
+		waiting_queries[ret] = nil;
+		if a then
+			prep_answer(a);
+			log_query("debug", "Results for %s %s %s: %s (%s, %f sec)", qname, qclass, qtype, a.rcode == 0 and (#a .. " items") or a.status,
+				a.secure and "Secure" or a.bogus or "Insecure", gotdataat - startedat); -- Insecure as in unsigned
+		else
+			log_query("error", "Results for %s %s %s: %s", qname, qclass, qtype, tostring(err));
+		end
+		local ok, cerr = pcall(callback, a, err);
+		if not ok then log_query("error", "Error in callback: %s", cerr); end
+	end
+	log_query("debug", "Resolve %s %s %s", qname, qclass, qtype);
+	local err;
+	ret, err = unbound:resolve_async(callback_wrapper, qname, ntype, nclass);
+	if ret then
+		waiting_queries[ret] = callback;
+	else
+		log_query("error", "Resolver error: %s", err);
+	end
+	return ret, err;
+end
+
+local function lookup_sync(qname, qtype, qclass)
+	if not unbound then initialize(); end
+	qtype = qtype and s_upper(qtype) or "A";
+	qclass = qclass and s_upper(qclass) or "IN";
+	local ntype, nclass = types[qtype], classes[qclass];
+	local a, err = unbound:resolve(qname, ntype, nclass);
+	if not a then return a, err; end
+	return prep_answer(a);
+end
+
+local function cancel(id)
+	local cb = waiting_queries[id];
+	unbound:cancel(id);
+	if cb then
+		cb(nil, "canceled");
+		waiting_queries[id] = nil;
+	end
+	return true;
+end
+
+-- Reinitiate libunbound context, drops cache
+local function purge()
+	for id in pairs(waiting_queries) do cancel(id); end
+	if server_conn then server_conn:close(); end
+	initialize();
+	return true;
+end
+
+local function not_implemented()
+	error "not implemented";
+end
+-- Public API
+local _M = {
+	lookup = lookup;
+	cancel = cancel;
+	new_async_socket = not_implemented;
+	dns = {
+		lookup = lookup_sync;
+		cancel = cancel;
+		cache = noop;
+		socket_wrapper_set = noop;
+		settimeout = noop;
+		query = noop;
+		purge = purge;
+		random = noop;
+		peek = noop;
+
+		types = types;
+		classes = classes;
+	};
+};
+
+local function lookup_promise(_, qname, qtype, qclass)
+	return promise.new(function (resolve, reject)
+		local function callback(answer, err)
+			if err then
+				return reject(err);
+			else
+				return resolve(answer);
+			end
+		end
+		local ret, err = lookup(callback, qname, qtype, qclass)
+		if not ret then reject(err); end
+	end);
+end
+
+local wrapper = {
+	lookup = function (_, callback, qname, qtype, qclass)
+		return lookup(callback, qname, qtype, qclass)
+	end;
+	lookup_promise = lookup_promise;
+	_resolver = {
+		settimeout = function () end;
+		closeall = function () end;
+	};
+}
+
+function _M.resolver() return wrapper; end
+
+return _M;
--- a/net/websocket.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/websocket.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -23,6 +23,7 @@
 local websocket_listeners = {};
 function websocket_listeners.ondisconnect(conn, err)
 	local s = websockets[conn];
+	if not s then return; end
 	websockets[conn] = nil;
 	if s.close_timer then
 		timer.stop(s.close_timer);
@@ -113,7 +114,7 @@
 				frame.MASK = true; -- RFC 6455 6.1.5: If the data is being sent by the client, the frame(s) MUST be masked
 				conn:write(frames.build(frame));
 			elseif frame.opcode == 0xA then -- Pong frame
-				log("debug", "Received unexpected pong frame: " .. tostring(frame.data));
+				log("debug", "Received unexpected pong frame: %s", frame.data);
 			else
 				return fail(s, 1002, "Reserved opcode");
 			end
@@ -131,7 +132,7 @@
 function websocket_methods:close(code, reason)
 	if self.readyState < 2 then
 		code = code or 1000;
-		log("debug", "closing WebSocket with code %i: %s" , code , tostring(reason));
+		log("debug", "closing WebSocket with code %i: %s" , code , reason);
 		self.readyState = 2;
 		local conn = self.conn;
 		conn:write(frames.build_close(code, reason, true));
@@ -245,7 +246,7 @@
 		   or (protocol and not protocol[r.headers["sec-websocket-protocol"]])
 		   then
 			s.readyState = 3;
-			log("warn", "WebSocket connection to %s failed: %s", url, tostring(b));
+			log("warn", "WebSocket connection to %s failed: %s", url, b);
 			if s.onerror then s:onerror("connecting-failed"); end
 			return;
 		end
--- a/net/websocket/frames.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/net/websocket/frames.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -6,72 +6,36 @@
 -- COPYING file in the source package for more information.
 --
 
-local softreq = require "util.dependencies".softreq;
 local random_bytes = require "util.random".bytes;
 
-local bit = assert(softreq"bit" or softreq"bit32",
-	"No bit module found. See https://prosody.im/doc/depends#bitop");
+local bit = require "util.bitcompat";
 local band = bit.band;
 local bor = bit.bor;
-local lshift = bit.lshift;
-local rshift = bit.rshift;
 local sbit = require "util.strbitop";
 local sxor = sbit.sxor;
 
-local s_char= string.char;
-local s_pack = string.pack; -- luacheck: ignore 143
-local s_unpack = string.unpack; -- luacheck: ignore 143
+local s_char = string.char;
+local s_pack = require"util.struct".pack;
+local s_unpack = require"util.struct".unpack;
 
-if not s_pack and softreq"struct" then
-	s_pack = softreq"struct".pack;
-	s_unpack = softreq"struct".unpack;
+local function pack_uint16be(x)
+	return s_pack(">I2", x);
+end
+local function pack_uint64be(x)
+	return s_pack(">I8", x);
 end
 
 local function read_uint16be(str, pos)
-	local l1, l2 = str:byte(pos, pos+1);
-	return l1*256 + l2;
-end
--- FIXME: this may lose precision
-local function read_uint64be(str, pos)
-	local l1, l2, l3, l4, l5, l6, l7, l8 = str:byte(pos, pos+7);
-	local h = lshift(l1, 24) + lshift(l2, 16) + lshift(l3, 8) + l4;
-	local l = lshift(l5, 24) + lshift(l6, 16) + lshift(l7, 8) + l8;
-	return h * 2^32 + l;
-end
-local function pack_uint16be(x)
-	return s_char(rshift(x, 8), band(x, 0xFF));
-end
-local function get_byte(x, n)
-	return band(rshift(x, n), 0xFF);
-end
-local function pack_uint64be(x)
-	local h = band(x / 2^32, 2^32-1);
-	return s_char(get_byte(h, 24), get_byte(h, 16), get_byte(h, 8), band(h, 0xFF),
-		get_byte(x, 24), get_byte(x, 16), get_byte(x, 8), band(x, 0xFF));
+	if type(str) ~= "string" then
+		str, pos = str:sub(pos, pos+1), 1;
+	end
+	return s_unpack(">I2", str, pos);
 end
-
-if s_pack then
-	function pack_uint16be(x)
-		return s_pack(">I2", x);
-	end
-	function pack_uint64be(x)
-		return s_pack(">I8", x);
+local function read_uint64be(str, pos)
+	if type(str) ~= "string" then
+		str, pos = str:sub(pos, pos+7), 1;
 	end
-end
-
-if s_unpack then
-	function read_uint16be(str, pos)
-		if type(str) ~= "string" then
-			str, pos = str:sub(pos, pos+1), 1;
-		end
-		return s_unpack(">I2", str, pos);
-	end
-	function read_uint64be(str, pos)
-		if type(str) ~= "string" then
-			str, pos = str:sub(pos, pos+7), 1;
-		end
-		return s_unpack(">I8", str, pos);
-	end
+	return s_unpack(">I8", str, pos);
 end
 
 local function parse_frame_header(frame)
--- a/plugins/adhoc/adhoc.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/adhoc/adhoc.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -21,7 +21,13 @@
 end
 
 function _M.new(name, node, handler, permission)
-	return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = (permission or "user") };
+	if not permission then
+		error "adhoc.new() expects a permission argument, none given"
+	end
+	if permission == "user" then
+		error "the permission mode 'user' has been renamed 'any', please update your code"
+	end
+	return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = permission };
 end
 
 function _M.handle_cmd(command, origin, stanza)
@@ -45,7 +51,7 @@
 		cmdreply = command:cmdtag("canceled", sessionid);
 	elseif data.status == "error" then
 		states[sessionid] = nil;
-		local reply = st.error_reply(stanza, data.error.type, data.error.condition, data.error.message);
+		local reply = st.error_reply(stanza, data.error);
 		origin.send(reply);
 		return true;
 	else
--- a/plugins/adhoc/mod_adhoc.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/adhoc/mod_adhoc.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,7 +8,7 @@
 local it = require "util.iterators";
 local st = require "util.stanza";
 local is_admin = require "core.usermanager".is_admin;
-local jid_split = require "util.jid".split;
+local jid_host = require "util.jid".host;
 local adhoc_handle_cmd = module:require "adhoc".handle_cmd;
 local xmlns_cmd = "http://jabber.org/protocol/commands";
 local commands = {};
@@ -21,12 +21,12 @@
 		local from = stanza.attr.from;
 		local privileged = is_admin(from, stanza.attr.to);
 		local global_admin = is_admin(from);
-		local username, hostname = jid_split(from);
+		local hostname = jid_host(from);
 		local command = commands[node];
 		if (command.permission == "admin" and privileged)
 		    or (command.permission == "global_admin" and global_admin)
 		    or (command.permission == "local_user" and hostname == module.host)
-		    or (command.permission == "user") then
+		    or (command.permission == "any") then
 			reply:tag("identity", { name = command.name,
 			    category = "automation", type = "command-node" }):up();
 			reply:tag("feature", { var = xmlns_cmd }):up();
@@ -52,12 +52,12 @@
 	local from = stanza.attr.from;
 	local admin = is_admin(from, stanza.attr.to);
 	local global_admin = is_admin(from);
-	local username, hostname = jid_split(from);
+	local hostname = jid_host(from);
 	for node, command in it.sorted_pairs(commands) do
 		if (command.permission == "admin" and admin)
 		    or (command.permission == "global_admin" and global_admin)
 		    or (command.permission == "local_user" and hostname == module.host)
-		    or (command.permission == "user") then
+		    or (command.permission == "any") then
 			reply:tag("item", { name = command.name,
 			    node = node, jid = module:get_host() });
 			reply:up();
@@ -74,7 +74,7 @@
 		local from = stanza.attr.from;
 		local admin = is_admin(from, stanza.attr.to);
 		local global_admin = is_admin(from);
-		local username, hostname = jid_split(from);
+		local hostname = jid_host(from);
 		if (command.permission == "admin" and not admin)
 		    or (command.permission == "global_admin" and not global_admin)
 		    or (command.permission == "local_user" and hostname ~= module.host) then
@@ -91,6 +91,8 @@
 
 local function adhoc_added(event)
 	local item = event.item;
+	-- Dang this was noisy
+	module:log("debug", "Command added by mod_%s: %q, %q", item._provided_by or "<unknown module>", item.name, item.node);
 	commands[item.node] = item;
 end
 
--- a/plugins/mod_admin_adhoc.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_admin_adhoc.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -18,7 +18,6 @@
 local usermanager_user_exists = require "core.usermanager".user_exists;
 local usermanager_create_user = require "core.usermanager".create_user;
 local usermanager_delete_user = require "core.usermanager".delete_user;
-local usermanager_get_password = require "core.usermanager".get_password;
 local usermanager_set_password = require "core.usermanager".set_password;
 local hostmanager_activate = require "core.hostmanager".activate;
 local hostmanager_deactivate = require "core.hostmanager".deactivate;
@@ -55,11 +54,11 @@
 	{ name = "password-verify", type = "text-private", label = "Retype password" };
 };
 
-local add_user_command_handler = adhoc_simple(add_user_layout, function(fields, err)
+local add_user_command_handler = adhoc_simple(add_user_layout, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
-	local username, host, resource = jid.split(fields.accountjid);
+	local username, host = jid.split(fields.accountjid);
 	if module_host ~= host then
 		return { status = "completed", error = { message = "Trying to add a user on " .. host .. " but command was sent to " .. module_host}};
 	end
@@ -68,7 +67,7 @@
 			return { status = "completed", error = { message = "Account already exists" } };
 		else
 			if usermanager_create_user(username, fields.password, host) then
-				module:log("info", "Created new account %s@%s", username, host);
+				module:log("info", "Created new account %s@%s by %s", username, host, jid.bare(data.from));
 				return { status = "completed", info = "Account successfully created" };
 			else
 				return { status = "completed", error = { message = "Failed to write data to disk" } };
@@ -90,11 +89,11 @@
 	{ name = "password", type = "text-private", required = true, label = "The password for this account" };
 };
 
-local change_user_password_command_handler = adhoc_simple(change_user_password_layout, function(fields, err)
+local change_user_password_command_handler = adhoc_simple(change_user_password_layout, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
-	local username, host, resource = jid.split(fields.accountjid);
+	local username, host = jid.split(fields.accountjid);
 	if module_host ~= host then
 		return {
 			status = "completed",
@@ -104,6 +103,7 @@
 		};
 	end
 	if usermanager_user_exists(username, host) and usermanager_set_password(username, fields.password, host, nil) then
+		module:log("info", "Password of account %s@%s changed by %s", username, host, jid.bare(data.from));
 		return { status = "completed", info = "Password successfully changed" };
 	else
 		return { status = "completed", error = { message = "User does not exist" } };
@@ -112,6 +112,7 @@
 
 -- Reloading the config
 local function config_reload_handler(self, data, state)
+	module:log("info", "%s reloads the config", jid.bare(data.from));
 	local ok, err = prosody.reload_config();
 	if ok then
 		return { status = "completed", info = "Configuration reloaded (modules may need to be reloaded for this to have an effect)" };
@@ -129,19 +130,19 @@
 	{ name = "accountjids", type = "jid-multi", required = true, label = "The Jabber ID(s) to delete" };
 };
 
-local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fields, err)
+local delete_user_command_handler = adhoc_simple(delete_user_layout, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
 	local failed = {};
 	local succeeded = {};
 	for _, aJID in ipairs(fields.accountjids) do
-		local username, host, resource = jid.split(aJID);
+		local username, host = jid.split(aJID);
 		if (host == module_host) and  usermanager_user_exists(username, host) and usermanager_delete_user(username, host) then
-			module:log("debug", "User %s has been deleted", aJID);
+			module:log("info", "User %s has been deleted by %s", aJID, jid.bare(data.from));
 			succeeded[#succeeded+1] = aJID;
 		else
-			module:log("debug", "Tried to delete non-existant user %s", aJID);
+			module:log("debug", "Tried to delete non-existent user %s", aJID);
 			failed[#failed+1] = aJID;
 		end
 	end
@@ -180,7 +181,7 @@
 	local failed = {};
 	local succeeded = {};
 	for _, aJID in ipairs(fields.accountjids) do
-		local username, host, resource = jid.split(aJID);
+		local username, host = jid.split(aJID);
 		if (host == module_host) and  usermanager_user_exists(username, host) and disconnect_user(aJID) then
 			succeeded[#succeeded+1] = aJID;
 		else
@@ -193,39 +194,6 @@
 		"The following accounts could not be disconnected:\n"..t_concat(failed, "\n") or "") };
 end);
 
--- Getting a user's password
-local get_user_password_layout = dataforms_new{
-	title = "Getting User's Password";
-	instructions = "Fill out this form to get a user's password.";
-
-	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
-	{ name = "accountjid", type = "jid-single", required = true, label = "The Jabber ID for which to retrieve the password" };
-};
-
-local get_user_password_result_layout = dataforms_new{
-	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
-	{ name = "accountjid", type = "jid-single", label = "JID" };
-	{ name = "password", type = "text-single", label = "Password" };
-};
-
-local get_user_password_handler = adhoc_simple(get_user_password_layout, function(fields, err)
-	if err then
-		return generate_error_message(err);
-	end
-	local user, host, resource = jid.split(fields.accountjid);
-	local accountjid;
-	local password;
-	if host ~= module_host then
-		return { status = "completed", error = { message = "Tried to get password for a user on " .. host .. " but command was sent to " .. module_host } };
-	elseif usermanager_user_exists(user, host) then
-		accountjid = fields.accountjid;
-		password = usermanager_get_password(user, host);
-	else
-		return { status = "completed", error = { message = "User does not exist" } };
-	end
-	return { status = "completed", result = { layout = get_user_password_result_layout, values = {accountjid = accountjid, password = password} } };
-end);
-
 -- Getting a user's roster
 local get_user_roster_layout = dataforms_new{
 	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
@@ -243,7 +211,7 @@
 		return generate_error_message(err);
 	end
 
-	local user, host, resource = jid.split(fields.accountjid);
+	local user, host = jid.split(fields.accountjid);
 	if host ~= module_host then
 		return { status = "completed", error = { message = "Tried to get roster for a user on " .. host .. " but command was sent to " .. module_host } };
 	elseif not usermanager_user_exists(user, host) then
@@ -286,7 +254,7 @@
 local get_user_stats_result_layout = dataforms_new{
 	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
 	{ name = "ipaddresses", type = "text-multi", label = "IP Addresses" };
-	{ name = "rostersize", type = "text-single", label = "Roster size" };
+	{ name = "rostersize", type = "text-single", label = "Roster size", datatype = "xs:integer" };
 	{ name = "onlineresources", type = "text-multi", label = "Online Resources" };
 };
 
@@ -314,7 +282,7 @@
 		resources = resources .. "\n" .. resource;
 		IPs = IPs .. "\n" .. session.ip;
 	end
-	return { status = "completed", result = {layout = get_user_stats_result_layout, values = {ipaddresses = IPs, rostersize = tostring(rostersize),
+	return { status = "completed", result = {layout = get_user_stats_result_layout, values = {ipaddresses = IPs, rostersize = rostersize,
 		onlineresources = resources}} };
 end);
 
@@ -373,10 +341,10 @@
 local list_s2s_this_result = dataforms_new {
 	title = "List of S2S connections on this host";
 
-	{ name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/s2s#list" };
-	{ name = "sessions", type = "text-multi", label = "Connections:" };
-	{ name = "num_in", type = "text-single", label = "#incoming connections:" };
-	{ name = "num_out", type = "text-single", label = "#outgoing connections:" };
+	{ name = "FORM_TYPE"; type = "hidden"; value = "http://prosody.im/protocol/s2s#list" };
+	{ name = "sessions"; type = "text-multi"; label = "Connections:" };
+	{ name = "num_in"; type = "text-single"; label = "#incoming connections:"; datatype = "xs:integer" };
+	{ name = "num_out"; type = "text-single"; label = "#outgoing connections:"; datatype = "xs:integer" };
 };
 
 local function session_flags(session, line)
@@ -392,6 +360,12 @@
 	if session.cert_identity_status == "valid" then
 		flags[#flags+1] = "authenticated";
 	end
+	if session.dialback_key then
+		flags[#flags+1] = "dialback";
+	end
+	if session.external_auth then
+		flags[#flags+1] = "SASL";
+	end
 	if session.secure then
 		flags[#flags+1] = "encrypted";
 	end
@@ -404,6 +378,12 @@
 	if session.ip and session.ip:match(":") then
 		flags[#flags+1] = "IPv6";
 	end
+	if session.incoming and session.outgoing then
+		flags[#flags+1] = "bidi";
+	elseif session.is_bidi or session.bidi_session then
+		flags[#flags+1] = "bidi";
+	end
+
 	line[#line+1] = "("..t_concat(flags, ", ")..")";
 
 	return t_concat(line, " ");
@@ -443,8 +423,8 @@
 
 	return { status = "completed", result = { layout = list_s2s_this_result; values = {
 		sessions = t_concat(s2s_list, "\n"),
-		num_in = tostring(count_in),
-		num_out = tostring(count_out)
+		num_in = count_in,
+		num_out = count_out
 	} } };
 end
 
@@ -495,7 +475,7 @@
 	{ name = "module", type = "text-single", required = true, label = "Module to globally load:"};
 };
 
-local globally_load_module_handler = adhoc_simple(globally_load_module_layout, function(fields, err)
+local globally_load_module_handler = adhoc_simple(globally_load_module_layout, function(fields, err, data)
 	local ok_list, err_list = {}, {};
 
 	if err then
@@ -511,6 +491,7 @@
 
 	-- Is this a global module?
 	if modulemanager.is_loaded("*", fields.module) and not modulemanager.is_loaded(module_host, fields.module) then
+		module:log("info", "mod_%s loaded by %s", fields.module, jid.bare(data.from));
 		return { status = "completed", info = 'Global module '..fields.module..' loaded.' };
 	end
 
@@ -526,6 +507,7 @@
 		end
 	end
 
+	module:log("info", "mod_%s loaded by %s", fields.module, jid.bare(data.from));
 	local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully loaded onto the hosts:\n"..t_concat(ok_list, "\n")) or "")
 		.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
 		(#err_list > 0 and ("Failed to load the module "..fields.module.." onto the hosts:\n"..t_concat(err_list, "\n")) or "");
@@ -543,7 +525,7 @@
 
 local reload_modules_handler = adhoc_initial(reload_modules_layout, function()
 	return { modules = array.collect(keys(hosts[module_host].modules)):sort() };
-end, function(fields, err)
+end, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
@@ -556,6 +538,7 @@
 			err_list[#err_list + 1] = module .. "(Error: " .. tostring(err) .. ")";
 		end
 	end
+	module:log("info", "mod_%s reloaded by %s", fields.module, jid.bare(data.from));
 	local info = (#ok_list > 0 and ("The following modules were successfully reloaded on host "..module_host..":\n"..t_concat(ok_list, "\n")) or "")
 		.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
 		(#err_list > 0 and ("Failed to reload the following modules on host "..module_host..":\n"..t_concat(err_list, "\n")) or "");
@@ -578,7 +561,7 @@
 	end
 	loaded_modules = array(set.new(loaded_modules):items()):sort();
 	return { module = loaded_modules };
-end, function(fields, err)
+end, function(fields, err, data)
 	local is_global = false;
 
 	if err then
@@ -613,6 +596,7 @@
 		end
 	end
 
+	module:log("info", "mod_%s reloaded by %s", fields.module, jid.bare(data.from));
 	local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully reloaded on the hosts:\n"..t_concat(ok_list, "\n")) or "")
 		.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
 		(#err_list > 0 and ("Failed to reload the module "..fields.module.." on the hosts:\n"..t_concat(err_list, "\n")) or "");
@@ -662,11 +646,13 @@
 	{ name = "announcement", type = "text-multi", label = "Announcement" };
 };
 
-local shut_down_service_handler = adhoc_simple(shut_down_service_layout, function(fields, err)
+local shut_down_service_handler = adhoc_simple(shut_down_service_layout, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
 
+	module:log("info", "Server being shut down by %s", jid.bare(data.from));
+
 	if fields.announcement and #fields.announcement > 0 then
 		local message = st.message({type = "headline"}, fields.announcement):up()
 			:tag("subject"):text("Server is shutting down");
@@ -689,7 +675,7 @@
 
 local unload_modules_handler = adhoc_initial(unload_modules_layout, function()
 	return { modules = array.collect(keys(hosts[module_host].modules)):sort() };
-end, function(fields, err)
+end, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
@@ -702,6 +688,7 @@
 			err_list[#err_list + 1] = module .. "(Error: " .. tostring(err) .. ")";
 		end
 	end
+	module:log("info", "mod_%s unloaded by %s", fields.module, jid.bare(data.from));
 	local info = (#ok_list > 0 and ("The following modules were successfully unloaded on host "..module_host..":\n"..t_concat(ok_list, "\n")) or "")
 		.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
 		(#err_list > 0 and ("Failed to unload the following modules on host "..module_host..":\n"..t_concat(err_list, "\n")) or "");
@@ -724,7 +711,7 @@
 	end
 	loaded_modules = array(set.new(loaded_modules):items()):sort();
 	return { module = loaded_modules };
-end, function(fields, err)
+end, function(fields, err, data)
 	local is_global = false;
 	if err then
 		return generate_error_message(err);
@@ -758,6 +745,7 @@
 		end
 	end
 
+	module:log("info", "mod_%s globally unloaded by %s", fields.module, jid.bare(data.from));
 	local info = (#ok_list > 0 and ("The module "..fields.module.." was successfully unloaded on the hosts:\n"..t_concat(ok_list, "\n")) or "")
 		.. ((#ok_list > 0 and #err_list > 0) and "\n" or "") ..
 		(#err_list > 0 and ("Failed to unload the module "..fields.module.." on the hosts:\n"..t_concat(err_list, "\n")) or "");
@@ -773,13 +761,14 @@
 	{ name = "host", type = "text-single", required = true, label = "Host:"};
 };
 
-local activate_host_handler = adhoc_simple(activate_host_layout, function(fields, err)
+local activate_host_handler = adhoc_simple(activate_host_layout, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
 	local ok, err = hostmanager_activate(fields.host);
 
 	if ok then
+		module:log("info", "Host '%s' activated by %s", fields.host, jid.bare(data.from));
 		return { status = "completed", info = fields.host .. " activated" };
 	else
 		return { status = "canceled", error = err }
@@ -795,13 +784,14 @@
 	{ name = "host", type = "text-single", required = true, label = "Host:"};
 };
 
-local deactivate_host_handler = adhoc_simple(deactivate_host_layout, function(fields, err)
+local deactivate_host_handler = adhoc_simple(deactivate_host_layout, function(fields, err, data)
 	if err then
 		return generate_error_message(err);
 	end
 	local ok, err = hostmanager_deactivate(fields.host);
 
 	if ok then
+		module:log("info", "Host '%s' deactivated by %s", fields.host, jid.bare(data.from));
 		return { status = "completed", info = fields.host .. " deactivated" };
 	else
 		return { status = "canceled", error = err }
@@ -815,7 +805,6 @@
 local config_reload_desc = adhoc_new("Reload configuration", "http://prosody.im/protocol/config#reload", config_reload_handler, "global_admin");
 local delete_user_desc = adhoc_new("Delete User", "http://jabber.org/protocol/admin#delete-user", delete_user_command_handler, "admin");
 local end_user_session_desc = adhoc_new("End User Session", "http://jabber.org/protocol/admin#end-user-session", end_user_session_handler, "admin");
-local get_user_password_desc = adhoc_new("Get User Password", "http://jabber.org/protocol/admin#get-user-password", get_user_password_handler, "admin");
 local get_user_roster_desc = adhoc_new("Get User Roster","http://jabber.org/protocol/admin#get-user-roster", get_user_roster_handler, "admin");
 local get_user_stats_desc = adhoc_new("Get User Statistics","http://jabber.org/protocol/admin#user-stats", get_user_stats_handler, "admin");
 local get_online_users_desc = adhoc_new("Get List of Online Users", "http://jabber.org/protocol/admin#get-online-users-list", get_online_users_command_handler, "admin");
@@ -836,7 +825,6 @@
 module:provides("adhoc", config_reload_desc);
 module:provides("adhoc", delete_user_desc);
 module:provides("adhoc", end_user_session_desc);
-module:provides("adhoc", get_user_password_desc);
 module:provides("adhoc", get_user_roster_desc);
 module:provides("adhoc", get_user_stats_desc);
 module:provides("adhoc", get_online_users_desc);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_admin_shell.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,1961 @@
+-- Prosody IM
+-- 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.
+--
+-- luacheck: ignore 212/self
+
+module:set_global();
+module:depends("admin_socket");
+
+local hostmanager = require "core.hostmanager";
+local modulemanager = require "core.modulemanager";
+local s2smanager = require "core.s2smanager";
+local portmanager = require "core.portmanager";
+local helpers = require "util.helpers";
+local server = require "net.server";
+local st = require "util.stanza";
+
+local _G = _G;
+
+local prosody = _G.prosody;
+
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local iterators = require "util.iterators";
+local keys, values = iterators.keys, iterators.values;
+local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join");
+local set, array = require "util.set", require "util.array";
+local cert_verify_identity = require "util.x509".verify_identity;
+local envload = require "util.envload".envload;
+local envloadfile = require "util.envload".envloadfile;
+local has_pposix, pposix = pcall(require, "util.pposix");
+local async = require "util.async";
+local serialization = require "util.serialization";
+local serialize_config = serialization.new ({ fatal = false, unquoted = true});
+local time = require "util.time";
+local promise = require "util.promise";
+
+local t_insert = table.insert;
+local t_concat = table.concat;
+
+local format_number = require "util.human.units".format;
+local format_table = require "util.human.io".table;
+
+local function capitalize(s)
+	if not s then return end
+	return (s:gsub("^%a", string.upper):gsub("_", " "));
+end
+
+local function pre(prefix, str, alt)
+	if type(str) ~= "string" or str == "" then return alt or ""; end
+	return prefix .. str;
+end
+
+local function suf(str, suffix, alt)
+	if type(str) ~= "string" or str == "" then return alt or ""; end
+	return str .. suffix;
+end
+
+local commands = module:shared("commands")
+local def_env = module:shared("env");
+local default_env_mt = { __index = def_env };
+
+local function redirect_output(target, session)
+	local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
+	env.dofile = function(name)
+		local f, err = envloadfile(name, env);
+		if not f then return f, err; end
+		return f();
+	end;
+	return env;
+end
+
+console = {};
+
+local runner_callbacks = {};
+
+function runner_callbacks:error(err)
+	module:log("error", "Traceback[shell]: %s", err);
+
+	self.data.print("Fatal error while running command, it did not complete");
+	self.data.print("Error: "..tostring(err));
+end
+
+local function send_repl_output(session, line)
+	return session.send(st.stanza("repl-output"):text(tostring(line)));
+end
+
+function console:new_session(admin_session)
+	local session = {
+		send = function (t)
+			return send_repl_output(admin_session, t);
+		end;
+		print = function (...)
+			local t = {};
+			for i=1,select("#", ...) do
+				t[i] = tostring(select(i, ...));
+			end
+			return send_repl_output(admin_session, table.concat(t, "\t"));
+		end;
+		serialize = tostring;
+		disconnect = function () admin_session:close(); end;
+	};
+	session.env = setmetatable({}, default_env_mt);
+
+	session.thread = async.runner(function (line)
+		console:process_line(session, line);
+	end, runner_callbacks, session);
+
+	-- Load up environment with helper objects
+	for name, t in pairs(def_env) do
+		if type(t) == "table" then
+			session.env[name] = setmetatable({ session = session }, { __index = t });
+		end
+	end
+
+	session.env.output:configure();
+
+	return session;
+end
+
+local function handle_line(event)
+	local session = event.origin.shell_session;
+	if not session then
+		session = console:new_session(event.origin);
+		event.origin.shell_session = session;
+	end
+	local line = event.stanza:get_text();
+	local useglobalenv;
+
+	local result = st.stanza("repl-result");
+
+	if line:match("^>") then
+		line = line:gsub("^>", "");
+		useglobalenv = true;
+	else
+		local command = line:match("^%w+") or line:match("%p");
+		if commands[command] then
+			commands[command](session, line);
+			event.origin.send(result);
+			return;
+		end
+	end
+
+	session.env._ = line;
+
+	if not useglobalenv and commands[line:lower()] then
+		commands[line:lower()](session, line);
+		event.origin.send(result);
+		return;
+	end
+
+	if useglobalenv and not session.globalenv then
+		session.globalenv = redirect_output(_G, session);
+	end
+
+	local chunkname = "=console";
+	local env = (useglobalenv and session.globalenv) or session.env or nil
+	-- luacheck: ignore 311/err
+	local chunk, err = envload("return "..line, chunkname, env);
+	if not chunk then
+		chunk, err = envload(line, chunkname, env);
+		if not chunk then
+			err = err:gsub("^%[string .-%]:%d+: ", "");
+			err = err:gsub("^:%d+: ", "");
+			err = err:gsub("'<eof>'", "the end of the line");
+			result.attr.type = "error";
+			result:text("Sorry, I couldn't understand that... "..err);
+			event.origin.send(result);
+			return;
+		end
+	end
+
+	local taskok, message = chunk();
+
+	if promise.is_promise(taskok) then
+		taskok, message = async.wait_for(taskok);
+	end
+
+	if not message then
+		if type(taskok) ~= "string" and useglobalenv then
+			taskok = session.serialize(taskok);
+		end
+		result:text("Result: "..tostring(taskok));
+	elseif (not taskok) and message then
+		result.attr.type = "error";
+		result:text("Error: "..tostring(message));
+	else
+		result:text("OK: "..tostring(message));
+	end
+
+	event.origin.send(result);
+end
+
+module:hook("admin/repl-input", function (event)
+	local ok, err = pcall(handle_line, event);
+	if not ok then
+		event.origin.send(st.stanza("repl-result", { type = "error" }):text(err));
+	end
+end);
+
+-- Console commands --
+-- These are simple commands, not valid standalone in Lua
+
+local available_columns; --forward declaration so it is reachable from the help
+
+function commands.help(session, data)
+	local print = session.print;
+	local section = data:match("^help (%w+)");
+	if not section then
+		print [[Commands are divided into multiple sections. For help on a particular section, ]]
+		print [[type: help SECTION (for example, 'help c2s'). Sections are: ]]
+		print [[]]
+		local row = format_table({ { title = "Section"; width = 7 }; { title = "Description"; width = "100%" } })
+		print(row())
+		print(row { "c2s"; "Commands to manage local client-to-server sessions" })
+		print(row { "s2s"; "Commands to manage sessions between this server and others" })
+		print(row { "http"; "Commands to inspect HTTP services" }) -- XXX plural but there is only one so far
+		print(row { "module"; "Commands to load/reload/unload modules/plugins" })
+		print(row { "host"; "Commands to activate, deactivate and list virtual hosts" })
+		print(row { "user"; "Commands to create and delete users, and change their passwords" })
+		print(row { "roles"; "Show information about user roles" })
+		print(row { "muc"; "Commands to create, list and manage chat rooms" })
+		print(row { "stats"; "Commands to show internal statistics" })
+		print(row { "server"; "Uptime, version, shutting down, etc." })
+		print(row { "port"; "Commands to manage ports the server is listening on" })
+		print(row { "dns"; "Commands to manage and inspect the internal DNS resolver" })
+		print(row { "xmpp"; "Commands for sending XMPP stanzas" })
+		print(row { "debug"; "Commands for debugging the server" })
+		print(row { "config"; "Reloading the configuration, etc." })
+		print(row { "columns"; "Information about customizing session listings" })
+		print(row { "console"; "Help regarding the console itself" })
+	elseif section == "c2s" then
+		print [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]]
+		print [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]]
+		print [[c2s:count() - Count sessions without listing them]]
+		print [[c2s:close(jid) - Close all sessions for the specified JID]]
+		print [[c2s:closeall() - Close all active c2s connections ]]
+	elseif section == "s2s" then
+		print [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]]
+		print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
+		print [[s2s:close(from, to) - Close a connection from one domain to another]]
+		print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
+	elseif section == "http" then
+		print [[http:list(hosts) - Show HTTP endpoints]]
+	elseif section == "module" then
+		print [[module:info(module, host) - Show information about a loaded module]]
+		print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
+		print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
+		print [[module:unload(module, host) - The same, but just unloads the module from memory]]
+		print [[module:list(host) - List the modules loaded on the specified host]]
+	elseif section == "host" then
+		print [[host:activate(hostname) - Activates the specified host]]
+		print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
+		print [[host:list() - List the currently-activated hosts]]
+	elseif section == "user" then
+		print [[user:create(jid, password, roles) - Create the specified user account]]
+		print [[user:password(jid, password) - Set the password for the specified user account]]
+		print [[user:roles(jid, host) - Show current roles for an user]]
+		print [[user:setroles(jid, host, roles) - Set roles for an user (see 'help roles')]]
+		print [[user:delete(jid) - Permanently remove the specified user account]]
+		print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
+	elseif section == "roles" then
+		print [[Roles may grant access or restrict users from certain operations]]
+		print [[Built-in roles are:]]
+		print [[  prosody:admin - Administrator]]
+		print [[  (empty set) - Normal user]]
+		print [[]]
+		print [[The canonical role format looks like: { ["example:role"] = true }]]
+		print [[For convenience, the following formats are also accepted:]]
+		print [["admin" - short for "prosody:admin", the normal admin status (like the admins config option)]]
+		print [["example:role" - short for {["example:role"]=true}]]
+		print [[{"example:role"} - short for {["example:role"]=true}]]
+	elseif section == "muc" then
+		-- TODO `muc:room():foo()` commands
+		print [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
+		print [[muc:list(host) - List rooms on the specified MUC component]]
+		print [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]]
+	elseif section == "server" then
+		print [[server:version() - Show the server's version number]]
+		print [[server:uptime() - Show how long the server has been running]]
+		print [[server:memory() - Show details about the server's memory usage]]
+		print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
+	elseif section == "port" then
+		print [[port:list() - Lists all network ports prosody currently listens on]]
+		print [[port:close(port, interface) - Close a port]]
+	elseif section == "dns" then
+		print [[dns:lookup(name, type, class) - Do a DNS lookup]]
+		print [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
+		print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
+		print [[dns:purge() - Clear the DNS cache]]
+		print [[dns:cache() - Show cached records]]
+	elseif section == "xmpp" then
+		print [[xmpp:ping(localhost, remotehost) -- Sends a ping to a remote XMPP server and reports the response]]
+	elseif section == "config" then
+		print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
+		print [[config:get([host,] option) - Show the value of a config option.]]
+	elseif section == "stats" then -- luacheck: ignore 542
+		print [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern]]
+		print [[stats:show():cfgraph() - Show a cumulative frequency graph]]
+		print [[stats:show():histogram() - Show a histogram of selected metric]]
+	elseif section == "debug" then
+		print [[debug:logevents(host) - Enable logging of fired events on host]]
+		print [[debug:events(host, event) - Show registered event handlers]]
+		print [[debug:timers() - Show information about scheduled timers]]
+	elseif section == "console" then
+		print [[Hey! Welcome to Prosody's admin console.]]
+		print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]]
+		print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]]
+		print [[so you may have trouble using the arrow keys, etc. depending on your system.]]
+		print [[]]
+		print [[For now we offer a couple of handy shortcuts:]]
+		print [[!! - Repeat the last command]]
+		print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']]
+		print [[]]
+		print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]]
+		print [[you can prefix a command with > to escape the console sandbox, and access everything in]]
+		print [[the running server. Great fun, but be careful not to break anything :)]]
+	elseif section == "columns" then
+		print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]]
+		print [['columns' argument as described here.]]
+		print [[]]
+		print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]]
+		print [[Available columns are:]]
+		local meta_columns = {
+			{ title = "ID"; width = 5 };
+			{ title = "Column Title"; width = 12 };
+			{ title = "Description"; width = 12 };
+		};
+		-- auto-adjust widths
+		for column, spec in pairs(available_columns) do
+			meta_columns[1].width = math.max(meta_columns[1].width or 0, #column);
+			meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or ""));
+			meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or ""));
+		end
+		local row = format_table(meta_columns, 120)
+		print(row());
+		for column, spec in iterators.sorted_pairs(available_columns) do
+			print(row({ column, spec.title, spec.description }));
+		end
+		print [[]]
+		print [[Most fields on the internal session structures can also be used as columns]]
+		-- Also, you can pass a table column specification directly, with mapper callback and all
+	end
+end
+
+-- Session environment --
+-- Anything in def_env will be accessible within the session as a global variable
+
+--luacheck: ignore 212/self
+local serialize_defaults = module:get_option("console_prettyprint_settings",
+	{ fatal = false; unquoted = true; maxdepth = 2; table_iterator = "pairs" })
+
+def_env.output = {};
+function def_env.output:configure(opts)
+	if type(opts) ~= "table" then
+		opts = { preset = opts };
+	end
+	if not opts.fallback then
+		-- XXX Error message passed to fallback is lost, does it matter?
+		opts.fallback = tostring;
+	end
+	for k,v in pairs(serialize_defaults) do
+		if opts[k] == nil then
+			opts[k] = v;
+		end
+	end
+	if opts.table_iterator == "pairs" then
+		opts.table_iterator = pairs;
+	elseif type(opts.table_iterator) ~= "function" then
+		opts.table_iterator = nil; -- rawpairs is the default
+	end
+	self.session.serialize = serialization.new(opts);
+end
+
+def_env.server = {};
+
+function def_env.server:insane_reload()
+	prosody.unlock_globals();
+	dofile "prosody"
+	prosody = _G.prosody;
+	return true, "Server reloaded";
+end
+
+function def_env.server:version()
+	return true, tostring(prosody.version or "unknown");
+end
+
+function def_env.server:uptime()
+	local t = os.time()-prosody.start_time;
+	local seconds = t%60;
+	t = (t - seconds)/60;
+	local minutes = t%60;
+	t = (t - minutes)/60;
+	local hours = t%24;
+	t = (t - hours)/24;
+	local days = t;
+	return true, string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)",
+		days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "",
+		minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
+end
+
+function def_env.server:shutdown(reason, code)
+	prosody.shutdown(reason, code);
+	return true, "Shutdown initiated";
+end
+
+local function human(kb)
+	return format_number(kb*1024, "B", "b");
+end
+
+function def_env.server:memory()
+	if not has_pposix or not pposix.meminfo then
+		return true, "Lua is using "..human(collectgarbage("count"));
+	end
+	local mem, lua_mem = pposix.meminfo(), collectgarbage("count");
+	local print = self.session.print;
+	print("Process: "..human((mem.allocated+mem.allocated_mmap)/1024));
+	print("   Used: "..human(mem.used/1024).." ("..human(lua_mem).." by Lua)");
+	print("   Free: "..human(mem.unused/1024).." ("..human(mem.returnable/1024).." returnable)");
+	return true, "OK";
+end
+
+def_env.module = {};
+
+local function get_hosts_set(hosts)
+	if type(hosts) == "table" then
+		if hosts[1] then
+			return set.new(hosts);
+		elseif hosts._items then
+			return hosts;
+		end
+	elseif type(hosts) == "string" then
+		return set.new { hosts };
+	elseif hosts == nil then
+		return set.new(array.collect(keys(prosody.hosts)));
+	end
+end
+
+-- Hosts with a module or all virtualhosts if no module given
+-- matching modules_enabled in the global section
+local function get_hosts_with_module(hosts, module)
+	local hosts_set = get_hosts_set(hosts)
+	/ function (host)
+			if module then
+				-- Module given, filter in hosts with this module loaded
+				if modulemanager.is_loaded(host, module) then
+					return host;
+				else
+					return nil;
+				end
+			end
+			if not hosts then
+				-- No hosts given, filter in VirtualHosts
+				if prosody.hosts[host].type == "local" then
+					return host;
+				else
+					return nil
+				end
+			end;
+			-- No module given, but hosts are, don't filter at all
+			return host;
+		end;
+	if module and modulemanager.get_module("*", module) then
+		hosts_set:add("*");
+	end
+	return hosts_set;
+end
+
+function def_env.module:info(name, hosts)
+	if not name then
+		return nil, "module name expected";
+	end
+	local print = self.session.print;
+	hosts = get_hosts_with_module(hosts, name);
+	if hosts:empty() then
+		return false, "mod_" .. name .. " does not appear to be loaded on the specified hosts";
+	end
+
+	local function item_name(item) return item.name; end
+
+	local friendly_descriptions = {
+		["adhoc-provider"] = "Ad-hoc commands",
+		["auth-provider"] = "Authentication provider",
+		["http-provider"] = "HTTP services",
+		["net-provider"] = "Network service",
+		["storage-provider"] = "Storage driver",
+		["measure"] = "Legacy metrics",
+		["metric"] = "Metrics",
+		["task"] = "Periodic task",
+	};
+	local item_formatters = {
+		["feature"] = tostring,
+		["identity"] = function(ident) return ident.type .. "/" .. ident.category; end,
+		["adhoc-provider"] = item_name,
+		["auth-provider"] = item_name,
+		["storage-provider"] = item_name,
+		["http-provider"] = function(item, mod) return mod:http_url(item.name, item.default_path); end,
+		["net-provider"] = item_name,
+		["measure"] = function(item) return item.name .. " (" .. suf(item.conf and item.conf.unit, " ") .. item.type .. ")"; end,
+		["metric"] = function(item)
+			return ("%s (%s%s)%s"):format(item.name, suf(item.mf.unit, " "), item.mf.type_, pre(": ", item.mf.description));
+		end,
+		["task"] = function (item) return string.format("%s (%s)", item.name or item.id, item.when); end
+	};
+
+	for host in hosts do
+		local mod = modulemanager.get_module(host, name);
+		if mod.module.host == "*" then
+			print("in global context");
+		else
+			print("on " .. tostring(prosody.hosts[mod.module.host]));
+		end
+		print("  path: " .. (mod.module.path or "n/a"));
+		if mod.module.status_message then
+			print("  status: [" .. mod.module.status_type .. "] " .. mod.module.status_message);
+		end
+		if mod.module.items and next(mod.module.items) ~= nil then
+			print("  provides:");
+			for kind, items in pairs(mod.module.items) do
+				local label = friendly_descriptions[kind] or kind:gsub("%-", " "):gsub("^%a", string.upper);
+				print(string.format("  - %s (%d item%s)", label, #items, #items > 1 and "s" or ""));
+				local formatter = item_formatters[kind];
+				if formatter then
+					for _, item in ipairs(items) do
+						print("    - " .. formatter(item, mod.module));
+					end
+				end
+			end
+		end
+		if mod.module.dependencies and next(mod.module.dependencies) ~= nil then
+			print("  dependencies:");
+			for dep in pairs(mod.module.dependencies) do
+				print("  - mod_" .. dep);
+			end
+		end
+	end
+	return true;
+end
+
+function def_env.module:load(name, hosts, config)
+	hosts = get_hosts_with_module(hosts);
+
+	-- Load the module for each host
+	local ok, err, count, mod = true, nil, 0;
+	for host in hosts do
+		if (not modulemanager.is_loaded(host, name)) then
+			mod, err = modulemanager.load(host, name, config);
+			if not mod then
+				ok = false;
+				if err == "global-module-already-loaded" then
+					if count > 0 then
+						ok, err, count = true, nil, 1;
+					end
+					break;
+				end
+				self.session.print(err or "Unknown error loading module");
+			else
+				count = count + 1;
+				self.session.print("Loaded for "..mod.module.host);
+			end
+		end
+	end
+
+	return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
+end
+
+function def_env.module:unload(name, hosts)
+	hosts = get_hosts_with_module(hosts, name);
+
+	-- Unload the module for each host
+	local ok, err, count = true, nil, 0;
+	for host in hosts do
+		if modulemanager.is_loaded(host, name) then
+			ok, err = modulemanager.unload(host, name);
+			if not ok then
+				ok = false;
+				self.session.print(err or "Unknown error unloading module");
+			else
+				count = count + 1;
+				self.session.print("Unloaded from "..host);
+			end
+		end
+	end
+	return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
+end
+
+local function _sort_hosts(a, b)
+	if a == "*" then return true
+	elseif b == "*" then return false
+	else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end
+end
+
+function def_env.module:reload(name, hosts)
+	hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts)
+
+	-- Reload the module for each host
+	local ok, err, count = true, nil, 0;
+	for _, host in ipairs(hosts) do
+		if modulemanager.is_loaded(host, name) then
+			ok, err = modulemanager.reload(host, name);
+			if not ok then
+				ok = false;
+				self.session.print(err or "Unknown error reloading module");
+			else
+				count = count + 1;
+				if ok == nil then
+					ok = true;
+				end
+				self.session.print("Reloaded on "..host);
+			end
+		end
+	end
+	return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
+end
+
+function def_env.module:list(hosts)
+	hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
+
+	local print = self.session.print;
+	for _, host in ipairs(hosts) do
+		print((host == "*" and "Global" or host)..":");
+		local modules = array.collect(keys(modulemanager.get_modules(host) or {})):sort();
+		if #modules == 0 then
+			if prosody.hosts[host] then
+				print("    No modules loaded");
+			else
+				print("    Host not found");
+			end
+		else
+			for _, name in ipairs(modules) do
+				local status, status_text = modulemanager.get_module(host, name).module:get_status();
+				local status_summary = "";
+				if status == "warn" or status == "error" then
+					status_summary = (" (%s: %s)"):format(status, status_text);
+				end
+				print(("    %s%s"):format(name, status_summary));
+			end
+		end
+	end
+end
+
+def_env.config = {};
+function def_env.config:load(filename, format)
+	local config_load = require "core.configmanager".load;
+	local ok, err = config_load(filename, format);
+	if not ok then
+		return false, err or "Unknown error loading config";
+	end
+	return true, "Config loaded";
+end
+
+function def_env.config:get(host, key)
+	if key == nil then
+		host, key = "*", host;
+	end
+	local config_get = require "core.configmanager".get
+	return true, serialize_config(config_get(host, key));
+end
+
+function def_env.config:reload()
+	local ok, err = prosody.reload_config();
+	return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
+end
+
+def_env.c2s = {};
+
+local function get_jid(session)
+	if session.username then
+		return session.full_jid or jid_join(session.username, session.host, session.resource);
+	end
+
+	local conn = session.conn;
+	local ip = session.ip or "?";
+	local clientport = conn and conn:clientport() or "?";
+	local serverip = conn and conn.server and conn:server():ip() or "?";
+	local serverport = conn and conn:serverport() or "?"
+	return jid_join("["..ip.."]:"..clientport, session.host or "["..serverip.."]:"..serverport);
+end
+
+local function get_c2s()
+	local c2s = array.collect(values(prosody.full_sessions));
+	c2s:append(array.collect(values(module:shared"/*/c2s/sessions")));
+	c2s:append(array.collect(values(module:shared"/*/bosh/sessions")));
+	c2s:unique();
+	return c2s;
+end
+
+local function _sort_by_jid(a, b)
+	if a.host == b.host then
+		if a.username == b.username then return (a.resource or "") > (b.resource or ""); end
+		return (a.username or "") > (b.username or "");
+	end
+	return _sort_hosts(a.host or "", b.host or "");
+end
+
+local function show_c2s(callback)
+	get_c2s():sort(_sort_by_jid):map(function (session)
+		callback(get_jid(session), session)
+	end);
+end
+
+function def_env.c2s:count()
+	local c2s = get_c2s();
+	return true, "Total: "..  #c2s .." clients";
+end
+
+local function get_s2s_hosts(session) --> local,remote
+	if session.direction == "outgoing" then
+		return session.host or session.from_host, session.to_host;
+	elseif session.direction == "incoming" then
+		return session.host or session.to_host, session.from_host;
+	end
+end
+
+available_columns = {
+	jid = {
+		title = "JID";
+		description = "Full JID of user session";
+		width = 32;
+		key = "full_jid";
+		mapper = function(full_jid, session) return full_jid or get_jid(session) end;
+	};
+	host = {
+		title = "Host";
+		description = "Local hostname";
+		key = "host";
+		width = 22;
+		mapper = function(host, session)
+			return host or get_s2s_hosts(session) or "?";
+		end;
+	};
+	remote = {
+		title = "Remote";
+		description = "Remote hostname";
+		width = 22;
+		mapper = function(_, session)
+			return select(2, get_s2s_hosts(session));
+		end;
+	};
+	port = {
+		title = "Port";
+		description = "Server port used";
+		width = 5;
+		align = "right";
+		key = "conn";
+		mapper = function(conn)
+			if conn then
+				return conn:serverport();
+			end
+		end;
+	};
+	dir = {
+		title = "Dir";
+		description = "Direction of server-to-server connection";
+		width = 3;
+		key = "direction";
+		mapper = function(dir, session)
+			if session.incoming and session.outgoing then return "<->"; end
+			if dir == "outgoing" then return "-->"; end
+			if dir == "incoming" then return "<--"; end
+		end;
+	};
+	id = { title = "Session ID"; description = "Internal session ID used in logging"; width = 20; key = "id" };
+	type = { title = "Type"; description = "Session type"; width = #"c2s_unauthed"; key = "type" };
+	method = {
+		title = "Method";
+		description = "Connection method";
+		width = 10;
+		mapper = function(_, session)
+			if session.bosh_version then
+				return "BOSH";
+			elseif session.websocket_request then
+				return "WebSocket";
+			else
+				return "TCP";
+			end
+		end;
+	};
+	ipv = {
+		title = "IPv";
+		description = "Internet Protocol version (4 or 6)";
+		width = 4;
+		key = "ip";
+		mapper = function(ip) if ip then return ip:find(":") and "IPv6" or "IPv4"; end end;
+	};
+	ip = { title = "IP address"; description = "IP address the session connected from"; width = 40; key = "ip" };
+	status = {
+		title = "Status";
+		description = "Presence status";
+		width = 6;
+		key = "presence";
+		mapper = function(p)
+			if not p then return ""; end
+			return p:get_child_text("show") or "online";
+		end;
+	};
+	secure = {
+		title = "Security";
+		description = "TLS version or security status";
+		key = "conn";
+		width = 8;
+		mapper = function(conn, session)
+			if not session.secure then return "insecure"; end
+			if not conn or not conn:ssl() then return "secure" end
+			local sock = conn and conn:socket();
+			if not sock then return "secure"; end
+			local tls_info = sock.info and sock:info();
+			return tls_info and tls_info.protocol or "secure";
+		end;
+	};
+	encryption = {
+		title = "Encryption";
+		description = "Encryption algorithm used (TLS cipher suite)";
+		width = 30;
+		key = "conn";
+		mapper = function(conn)
+			local sock = conn and conn:socket();
+			local info = sock and sock.info and sock:info();
+			if info then return info.cipher end
+		end;
+	};
+	cert = {
+		title = "Certificate";
+		description = "Validation status of certificate";
+		key = "cert_identity_status";
+		width = 11;
+		mapper = function(cert_status, session)
+			if cert_status then return capitalize(cert_status); end
+			if session.cert_chain_status == "Invalid" then
+				local cert_errors = set.new(session.cert_chain_errors[1]);
+				if cert_errors:contains("certificate has expired") then
+					return "Expired";
+				elseif cert_errors:contains("self signed certificate") then
+					return "Self-signed";
+				end
+				return "Untrusted";
+			elseif session.cert_identity_status == "invalid" then
+				return "Mismatched";
+			end
+			return "Unknown";
+		end;
+	};
+	sni = {
+		title = "SNI";
+		description = "Hostname requested in TLS";
+		width = 22;
+		mapper = function(_, session)
+			if not session.conn then return end
+			local sock = session.conn:socket();
+			return sock and sock.getsniname and sock:getsniname() or "";
+		end;
+	};
+	alpn = {
+		title = "ALPN";
+		description = "Protocol requested in TLS";
+		width = 11;
+		mapper = function(_, session)
+			if not session.conn then return end
+			local sock = session.conn:socket();
+			return sock and sock.getalpn and sock:getalpn() or "";
+		end;
+	};
+	smacks = {
+		title = "SM";
+		description = "Stream Management (XEP-0198) status";
+		key = "smacks";
+		width = 11;
+		mapper = function(smacks_xmlns, session)
+			if not smacks_xmlns then return "no"; end
+			if session.hibernating then return "hibernating"; end
+			return "yes";
+		end;
+	};
+	smacks_queue = {
+		title = "SM Queue";
+		description = "Length of Stream Management stanza queue";
+		key = "outgoing_stanza_queue";
+		width = 8;
+		align = "right";
+		mapper = function (queue)
+			return queue and tostring(queue:count_unacked());
+		end
+	};
+	csi = {
+		title = "CSI State";
+		description = "Client State Indication (XEP-0352)";
+		key = "state";
+		-- TODO include counter
+	};
+	s2s_sasl = {
+		title = "SASL";
+		description = "Server authentication status";
+		key = "external_auth";
+		width = 10;
+		mapper = capitalize
+	};
+	dialback = {
+		title = "Dialback";
+		description = "Legacy server verification";
+		key = "dialback_key";
+		width = 13;
+		mapper = function (dialback_key, session)
+			if not dialback_key then
+				if session.type == "s2sin" or session.type == "s2sout" then
+					return "Not used";
+				end
+				return "Not initiated";
+			elseif session.type == "s2sin_unauthed" or session.type == "s2sout_unauthed" then
+				return "Initiated";
+			else
+				return "Completed";
+			end
+		end
+	};
+};
+
+local function get_colspec(colspec, default)
+	if type(colspec) == "string" then colspec = array(colspec:gmatch("%S+")); end
+	local columns = {};
+	for i, col in pairs(colspec or default) do
+		if type(col) == "string" then
+			columns[i] = available_columns[col] or { title = capitalize(col); width = 20; key = col };
+		elseif type(col) ~= "table" then
+			return false, ("argument %d: expected string|table but got %s"):format(i, type(col));
+		else
+			columns[i] = col;
+		end
+	end
+
+	return columns;
+end
+
+function def_env.c2s:show(match_jid, colspec)
+	local print = self.session.print;
+	local columns = get_colspec(colspec, { "id"; "jid"; "ipv"; "status"; "secure"; "smacks"; "csi" });
+	local row = format_table(columns, 120);
+
+	local function match(session)
+		local jid = get_jid(session)
+		return (not match_jid) or jid == match_jid;
+	end
+
+	local group_by_host = true;
+	for _, col in ipairs(columns) do
+		if col.key == "full_jid" or col.key == "host" then
+			group_by_host = false;
+			break
+		end
+	end
+
+	if not group_by_host then print(row()); end
+	local currenthost = nil;
+
+	local c2s_sessions = get_c2s();
+	local total_count = #c2s_sessions;
+	c2s_sessions:filter(match):sort(_sort_by_jid);
+	local shown_count = #c2s_sessions;
+	for _, session in ipairs(c2s_sessions) do
+		if group_by_host and session.host ~= currenthost then
+			currenthost = session.host;
+			print("#",prosody.hosts[currenthost] or "Unknown host");
+			print(row());
+		end
+
+		print(row(session));
+	end
+	if total_count ~= shown_count then
+		return true, ("%d out of %d c2s sessions shown"):format(shown_count, total_count);
+	end
+	return true, ("%d c2s sessions shown"):format(total_count);
+end
+
+function def_env.c2s:show_tls(match_jid)
+	return self:show(match_jid, { "jid"; "id"; "secure"; "encryption" });
+end
+
+local function build_reason(text, condition)
+	if text or condition then
+		return {
+			text = text,
+			condition = condition or "undefined-condition",
+		};
+	end
+end
+
+function def_env.c2s:close(match_jid, text, condition)
+	local count = 0;
+	show_c2s(function (jid, session)
+		if jid == match_jid or jid_bare(jid) == match_jid then
+			count = count + 1;
+			session:close(build_reason(text, condition));
+		end
+	end);
+	return true, "Total: "..count.." sessions closed";
+end
+
+function def_env.c2s:closeall(text, condition)
+	local count = 0;
+	--luacheck: ignore 212/jid
+	show_c2s(function (jid, session)
+		count = count + 1;
+		session:close(build_reason(text, condition));
+	end);
+	return true, "Total: "..count.." sessions closed";
+end
+
+
+def_env.s2s = {};
+local function _sort_s2s(a, b)
+	local a_local, a_remote = get_s2s_hosts(a);
+	local b_local, b_remote = get_s2s_hosts(b);
+	if (a_local or "") == (b_local or "") then return _sort_hosts(a_remote or "", b_remote or ""); end
+	return _sort_hosts(a_local or "", b_local or "");
+end
+
+function def_env.s2s:show(match_jid, colspec)
+	local print = self.session.print;
+	local columns = get_colspec(colspec, { "id"; "host"; "dir"; "remote"; "ipv"; "secure"; "s2s_sasl"; "dialback" });
+	local row = format_table(columns, 132);
+
+	local function match(session)
+		local host, remote = get_s2s_hosts(session);
+		return not match_jid or host == match_jid or remote == match_jid;
+	end
+
+	local group_by_host = true;
+	local currenthost = nil;
+	for _, col in ipairs(columns) do
+		if col.key == "host" then
+			group_by_host = false;
+			break
+		end
+	end
+
+	if not group_by_host then print(row()); end
+
+	local s2s_sessions = array(iterators.values(module:shared"/*/s2s/sessions"));
+	local total_count = #s2s_sessions;
+	s2s_sessions:filter(match):sort(_sort_s2s);
+	local shown_count = #s2s_sessions;
+
+	for _, session in ipairs(s2s_sessions) do
+		if group_by_host and currenthost ~= get_s2s_hosts(session) then
+			currenthost = get_s2s_hosts(session);
+			print("#",prosody.hosts[currenthost] or "Unknown host");
+			print(row());
+		end
+
+		print(row(session));
+	end
+	if total_count ~= shown_count then
+		return true, ("%d out of %d s2s connections shown"):format(shown_count, total_count);
+	end
+	return true, ("%d s2s connections shown"):format(total_count);
+end
+
+function def_env.s2s:show_tls(match_jid)
+	return self:show(match_jid, { "id"; "host"; "dir"; "remote"; "secure"; "encryption"; "cert" });
+end
+
+local function print_subject(print, subject)
+	for _, entry in ipairs(subject) do
+		print(
+			("    %s: %q"):format(
+				entry.name or entry.oid,
+				entry.value:gsub("[\r\n%z%c]", " ")
+			)
+		);
+	end
+end
+
+-- As much as it pains me to use the 0-based depths that OpenSSL does,
+-- I think there's going to be more confusion among operators if we
+-- break from that.
+local function print_errors(print, errors)
+	for depth, t in pairs(errors) do
+		print(
+			("    %d: %s"):format(
+				depth-1,
+				table.concat(t, "\n|        ")
+			)
+		);
+	end
+end
+
+function def_env.s2s:showcert(domain)
+	local print = self.session.print;
+	local s2s_sessions = module:shared"/*/s2s/sessions";
+	local domain_sessions = set.new(array.collect(values(s2s_sessions)))
+		/function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end;
+	local cert_set = {};
+	for session in domain_sessions do
+		local conn = session.conn;
+		conn = conn and conn:socket();
+		if not conn.getpeerchain then
+			if conn.dohandshake then
+				error("This version of LuaSec does not support certificate viewing");
+			end
+		else
+			local cert = conn:getpeercertificate();
+			if cert then
+				local certs = conn:getpeerchain();
+				local digest = cert:digest("sha1");
+				if not cert_set[digest] then
+					local chain_valid, chain_errors = conn:getpeerverification();
+					cert_set[digest] = {
+						{
+						  from = session.from_host,
+						  to = session.to_host,
+						  direction = session.direction
+						};
+						chain_valid = chain_valid;
+						chain_errors = chain_errors;
+						certs = certs;
+					};
+				else
+					table.insert(cert_set[digest], {
+						from = session.from_host,
+						to = session.to_host,
+						direction = session.direction
+					});
+				end
+			end
+		end
+	end
+	local domain_certs = array.collect(values(cert_set));
+	-- Phew. We now have a array of unique certificates presented by domain.
+	local n_certs = #domain_certs;
+
+	if n_certs == 0 then
+		return "No certificates found for "..domain;
+	end
+
+	local function _capitalize_and_colon(byte)
+		return string.upper(byte)..":";
+	end
+	local function pretty_fingerprint(hash)
+		return hash:gsub("..", _capitalize_and_colon):sub(1, -2);
+	end
+
+	for cert_info in values(domain_certs) do
+		local certs = cert_info.certs;
+		local cert = certs[1];
+		print("---")
+		print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1")));
+		print("");
+		local n_streams = #cert_info;
+		print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":");
+		for _, stream in ipairs(cert_info) do
+			if stream.direction == "incoming" then
+				print("    "..stream.to.." <- "..stream.from);
+			else
+				print("    "..stream.from.." -> "..stream.to);
+			end
+		end
+		print("");
+		local chain_valid, errors = cert_info.chain_valid, cert_info.chain_errors;
+		local valid_identity = cert_verify_identity(domain, "xmpp-server", cert);
+		if chain_valid then
+			print("Trusted certificate: Yes");
+		else
+			print("Trusted certificate: No");
+			print_errors(print, errors);
+		end
+		print("");
+		print("Issuer: ");
+		print_subject(print, cert:issuer());
+		print("");
+		print("Valid for "..domain..": "..(valid_identity and "Yes" or "No"));
+		print("Subject:");
+		print_subject(print, cert:subject());
+	end
+	print("---");
+	return ("Showing "..n_certs.." certificate"
+		..(n_certs==1 and "" or "s")
+		.." presented by "..domain..".");
+end
+
+function def_env.s2s:close(from, to, text, condition)
+	local print, count = self.session.print, 0;
+	local s2s_sessions = module:shared"/*/s2s/sessions";
+
+	local match_id;
+	if from and not to then
+		match_id, from = from, nil;
+	elseif not to then
+		return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'";
+	elseif from == to then
+		return false, "Both from and to are the same... you can't do that :)";
+	end
+
+	for _, session in pairs(s2s_sessions) do
+		local id = session.id or (session.type..tostring(session):match("[a-f0-9]+$"));
+		if (match_id and match_id == id)
+		or (session.from_host == from and session.to_host == to) then
+			print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id));
+			(session.close or s2smanager.destroy_session)(session, build_reason(text, condition));
+			count = count + 1 ;
+		end
+	end
+	return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s");
+end
+
+function def_env.s2s:closeall(host, text, condition)
+	local count = 0;
+	local s2s_sessions = module:shared"/*/s2s/sessions";
+	for _,session in pairs(s2s_sessions) do
+		if not host or session.from_host == host or session.to_host == host then
+			session:close(build_reason(text, condition));
+			count = count + 1;
+		end
+	end
+	if count == 0 then return false, "No sessions to close.";
+	else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end
+end
+
+def_env.host = {}; def_env.hosts = def_env.host;
+
+function def_env.host:activate(hostname, config)
+	return hostmanager.activate(hostname, config);
+end
+function def_env.host:deactivate(hostname, reason)
+	return hostmanager.deactivate(hostname, reason);
+end
+
+function def_env.host:list()
+	local print = self.session.print;
+	local i = 0;
+	local type;
+	for host, host_session in iterators.sorted_pairs(prosody.hosts, _sort_hosts) do
+		i = i + 1;
+		type = host_session.type;
+		if type == "local" then
+			print(host);
+		else
+			type = module:context(host):get_option_string("component_module", type);
+			if type ~= "component" then
+				type = type .. " component";
+			end
+			print(("%s (%s)"):format(host, type));
+		end
+	end
+	return true, i.." hosts";
+end
+
+def_env.port = {};
+
+function def_env.port:list()
+	local print = self.session.print;
+	local services = portmanager.get_active_services().data;
+	local n_services, n_ports = 0, 0;
+	for service, interfaces in iterators.sorted_pairs(services) do
+		n_services = n_services + 1;
+		local ports_list = {};
+		for interface, ports in pairs(interfaces) do
+			for port in pairs(ports) do
+				table.insert(ports_list, "["..interface.."]:"..port);
+			end
+		end
+		n_ports = n_ports + #ports_list;
+		print(service..": "..table.concat(ports_list, ", "));
+	end
+	return true, n_services.." services listening on "..n_ports.." ports";
+end
+
+function def_env.port:close(close_port, close_interface)
+	close_port = assert(tonumber(close_port), "Invalid port number");
+	local n_closed = 0;
+	local services = portmanager.get_active_services().data;
+	for service, interfaces in pairs(services) do -- luacheck: ignore 213
+		for interface, ports in pairs(interfaces) do
+			if not close_interface or close_interface == interface then
+				if ports[close_port] then
+					self.session.print("Closing ["..interface.."]:"..close_port.."...");
+					local ok, err = portmanager.close(interface, close_port)
+					if not ok then
+						self.session.print("Failed to close "..interface.." "..close_port..": "..err);
+					else
+						n_closed = n_closed + 1;
+					end
+				end
+			end
+		end
+	end
+	return true, "Closed "..n_closed.." ports";
+end
+
+def_env.muc = {};
+
+local console_room_mt = {
+	__index = function (self, k) return self.room[k]; end;
+	__tostring = function (self)
+		return "MUC room <"..self.room.jid..">";
+	end;
+};
+
+local function check_muc(jid)
+	local room_name, host = jid_split(jid);
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif not prosody.hosts[host].modules.muc then
+		return nil, "Host '"..host.."' is not a MUC service";
+	end
+	return room_name, host;
+end
+
+function def_env.muc:create(room_jid, config)
+	local room_name, host = check_muc(room_jid);
+	if not room_name then
+		return room_name, host;
+	end
+	if not room_name then return nil, host end
+	if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end
+	if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end
+	return prosody.hosts[host].modules.muc.create_room(room_jid, config);
+end
+
+function def_env.muc:room(room_jid)
+	local room_name, host = check_muc(room_jid);
+	if not room_name then
+		return room_name, host;
+	end
+	local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
+	if not room_obj then
+		return nil, "No such room: "..room_jid;
+	end
+	return setmetatable({ room = room_obj }, console_room_mt);
+end
+
+function def_env.muc:list(host)
+	local host_session = prosody.hosts[host];
+	if not host_session or not host_session.modules.muc then
+		return nil, "Please supply the address of a local MUC component";
+	end
+	local print = self.session.print;
+	local c = 0;
+	for room in host_session.modules.muc.each_room() do
+		print(room.jid);
+		c = c + 1;
+	end
+	return true, c.." rooms";
+end
+
+local um = require"core.usermanager";
+
+local function coerce_roles(roles)
+	if roles == "admin" then roles = "prosody:admin"; end
+	if type(roles) == "string" then roles = { [roles] = true }; end
+	if roles[1] then for i, role in ipairs(roles) do roles[role], roles[i] = true, nil; end end
+	return roles;
+end
+
+def_env.user = {};
+function def_env.user:create(jid, password, roles)
+	local username, host = jid_split(jid);
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif um.user_exists(username, host) then
+		return nil, "User exists";
+	end
+	local ok, err = um.create_user(username, password, host);
+	if ok then
+		if ok and roles then
+			roles = coerce_roles(roles);
+			local roles_ok, rerr = um.set_roles(jid, host, roles);
+			if not roles_ok then return nil, "User created, but could not set roles: " .. tostring(rerr); end
+		end
+		return true, "User created";
+	else
+		return nil, "Could not create user: "..err;
+	end
+end
+
+function def_env.user:delete(jid)
+	local username, host = jid_split(jid);
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif not um.user_exists(username, host) then
+		return nil, "No such user";
+	end
+	local ok, err = um.delete_user(username, host);
+	if ok then
+		return true, "User deleted";
+	else
+		return nil, "Could not delete user: "..err;
+	end
+end
+
+function def_env.user:password(jid, password)
+	local username, host = jid_split(jid);
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif not um.user_exists(username, host) then
+		return nil, "No such user";
+	end
+	local ok, err = um.set_password(username, password, host, nil);
+	if ok then
+		return true, "User password changed";
+	else
+		return nil, "Could not change password for user: "..err;
+	end
+end
+
+function def_env.user:roles(jid, host, new_roles)
+	if new_roles or type(host) == "table" then
+		return nil, "Use user:setroles(jid, host, roles) to change user roles";
+	end
+	local username, userhost = jid_split(jid);
+	if host == nil then host = userhost; end
+	if host ~= "*" and not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+		return nil, "No such user";
+	end
+	local roles = um.get_roles(jid, host);
+	if not roles then return true, "No roles"; end
+	local count = 0;
+	local print = self.session.print;
+	for role in pairs(roles) do
+		count = count + 1;
+		print(role);
+	end
+	return true, count == 1 and "1 role" or count.." roles";
+end
+def_env.user.showroles = def_env.user.roles; -- COMPAT
+
+-- user:roles("someone@example.com", "example.com", {"prosody:admin"})
+-- user:roles("someone@example.com", {"prosody:admin"})
+function def_env.user:setroles(jid, host, new_roles)
+	local username, userhost = jid_split(jid);
+	if new_roles == nil then host, new_roles = userhost, host; end
+	if host ~= "*" and not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+		return nil, "No such user";
+	end
+	if host == "*" then host = nil; end
+	return um.set_roles(jid, host, coerce_roles(new_roles));
+end
+
+-- TODO switch to table view, include roles
+function def_env.user:list(host, pat)
+	if not host then
+		return nil, "No host given";
+	elseif not prosody.hosts[host] then
+		return nil, "No such host";
+	end
+	local print = self.session.print;
+	local total, matches = 0, 0;
+	for user in um.users(host) do
+		if not pat or user:match(pat) then
+			print(user.."@"..host);
+			matches = matches + 1;
+		end
+		total = total + 1;
+	end
+	return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users";
+end
+
+def_env.xmpp = {};
+
+local new_id = require "util.id".medium;
+function def_env.xmpp:ping(localhost, remotehost, timeout)
+	localhost = select(2, jid_split(localhost));
+	remotehost = select(2, jid_split(remotehost));
+	if not localhost then
+		return nil, "Invalid sender hostname";
+	elseif not prosody.hosts[localhost] then
+		return nil, "No such local host";
+	end
+	if not remotehost then
+		return nil, "Invalid destination hostname";
+	elseif prosody.hosts[remotehost] then
+		return nil, "Both hosts are local";
+	end
+	local iq = st.iq{ from=localhost, to=remotehost, type="get", id=new_id()}
+			:tag("ping", {xmlns="urn:xmpp:ping"});
+	local time_start = time.now();
+	local print = self.session.print;
+	local function onchange(what)
+		return function(event)
+			local s2s_session = event.session;
+			if (s2s_session.from_host == localhost and s2s_session.to_host == remotehost)
+				or (s2s_session.to_host == localhost and s2s_session.from_host == remotehost) then
+				local dir = available_columns.dir.mapper(s2s_session.direction, s2s_session);
+				print(("Session %s (%s%s%s) %s (%gs)"):format(s2s_session.id, localhost, dir, remotehost, what,
+					time.now() - time_start));
+			elseif s2s_session.type == "s2sin_unauthed" and s2s_session.to_host == nil and s2s_session.from_host == nil then
+				print(("Session %s %s (%gs)"):format(s2s_session.id, what, time.now() - time_start));
+			end
+		end
+	end
+	local onconnected = onchange("connected");
+	local onauthenticated = onchange("authenticated");
+	local onestablished = onchange("established");
+	local ondestroyed = onchange("destroyed");
+	module:hook("s2s-connected", onconnected, 1);
+	module:context(localhost):hook("s2s-authenticated", onauthenticated, 1);
+	module:hook("s2sout-established", onestablished, 1);
+	module:hook("s2sin-established", onestablished, 1);
+	module:hook("s2s-destroyed", ondestroyed, 1);
+	return module:context(localhost):send_iq(iq, nil, timeout):finally(function()
+		module:unhook("s2s-connected", onconnected, 1);
+		module:context(localhost):unhook("s2s-authenticated", onauthenticated);
+		module:unhook("s2sout-established", onestablished);
+		module:unhook("s2sin-established", onestablished);
+		module:unhook("s2s-destroyed", ondestroyed);
+	end):next(function(pong)
+		return ("pong from %s in %gs"):format(pong.stanza.attr.from, time.now() - time_start);
+	end);
+end
+
+def_env.dns = {};
+local adns = require"net.adns";
+
+local function get_resolver(session)
+	local resolver = session.dns_resolver;
+	if not resolver then
+		resolver = adns.resolver();
+		session.dns_resolver = resolver;
+	end
+	return resolver;
+end
+
+function def_env.dns:lookup(name, typ, class)
+	local resolver = get_resolver(self.session);
+	return resolver:lookup_promise(name, typ, class)
+end
+
+function def_env.dns:addnameserver(...)
+	local resolver = get_resolver(self.session);
+	resolver._resolver:addnameserver(...)
+	return true
+end
+
+function def_env.dns:setnameserver(...)
+	local resolver = get_resolver(self.session);
+	resolver._resolver:setnameserver(...)
+	return true
+end
+
+function def_env.dns:purge()
+	local resolver = get_resolver(self.session);
+	resolver._resolver:purge()
+	return true
+end
+
+function def_env.dns:cache()
+	local resolver = get_resolver(self.session);
+	return true, "Cache:\n"..tostring(resolver._resolver.cache)
+end
+
+def_env.http = {};
+
+function def_env.http:list(hosts)
+	local print = self.session.print;
+	hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
+	local output = format_table({
+			{ title = "Module", width = "20%" },
+			{ title = "URL", width = "80%" },
+		}, 132);
+
+	for _, host in ipairs(hosts) do
+		local http_apps = modulemanager.get_items("http-provider", host);
+		if #http_apps > 0 then
+			local http_host = module:context(host):get_option_string("http_host");
+			if host == "*" then
+				print("Global HTTP endpoints available on all hosts:");
+			else
+				print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":"));
+			end
+			print(output());
+			for _, provider in ipairs(http_apps) do
+				local mod = provider._provided_by;
+				local url = module:context(host):http_url(provider.name, provider.default_path);
+				mod = mod and "mod_"..mod or ""
+				print(output{mod, url});
+			end
+			print("");
+		end
+	end
+
+	local default_host = module:get_option_string("http_default_host");
+	if not default_host then
+		print("HTTP requests to unknown hosts will return 404 Not Found");
+	else
+		print("HTTP requests to unknown hosts will be handled by "..default_host);
+	end
+	return true;
+end
+
+def_env.debug = {};
+
+function def_env.debug:logevents(host)
+	helpers.log_host_events(host);
+	return true;
+end
+
+function def_env.debug:events(host, event)
+	local events_obj;
+	if host and host ~= "*" then
+		if host == "http" then
+			events_obj = require "net.http.server"._events;
+		elseif not prosody.hosts[host] then
+			return false, "Unknown host: "..host;
+		else
+			events_obj = prosody.hosts[host].events;
+		end
+	else
+		events_obj = prosody.events;
+	end
+	return true, helpers.show_events(events_obj, event);
+end
+
+function def_env.debug:timers()
+	local print = self.session.print;
+	local add_task = require"util.timer".add_task;
+	local h, params = add_task.h, add_task.params;
+	local function normalize_time(t)
+		return t;
+	end
+	local function format_time(t)
+		return os.date("%F %T", math.floor(normalize_time(t)));
+	end
+	if h then
+		print("-- util.timer");
+	elseif server.timer then
+		print("-- net.server.timer");
+		h = server.timer.add_task.timers;
+		normalize_time = server.timer.to_absolute_time or normalize_time;
+	end
+	if h then
+		local timers = {};
+		for i, id in ipairs(h.ids) do
+			local t, cb = h.priorities[i], h.items[id];
+			if not params then
+				local param = cb.param;
+				if param then
+					cb = param.callback;
+				else
+					cb = cb.timer_callback or cb;
+				end
+			elseif params[id] then
+				cb = params[id].callback or cb;
+			end
+			table.insert(timers, { format_time(t), cb });
+		end
+		table.sort(timers, function (a, b) return a[1] < b[1] end);
+		for _, t in ipairs(timers) do
+			print(t[1], t[2])
+		end
+	end
+	if server.event_base then
+		local count = 0;
+		for _, v in pairs(debug.getregistry()) do
+			if type(v) == "function" and v.callback and v.callback == add_task._on_timer then
+				count = count + 1;
+			end
+		end
+		print(count .. " libevent callbacks");
+	end
+	if h then
+		local next_time = h:peek();
+		if next_time then
+			return true, ("Next event at %s (in %.6fs)"):format(format_time(next_time), normalize_time(next_time) - time.now());
+		end
+	end
+	return true;
+end
+
+-- COMPAT: debug:timers() was timer:info() for some time in trunk
+def_env.timer = { info = def_env.debug.timers };
+
+def_env.stats = {};
+
+local short_units = {
+	seconds = "s",
+	bytes = "B",
+};
+
+local stats_methods = {};
+
+function stats_methods:render_single_fancy_histogram_ex(print, prefix, metric_family, metric, cumulative)
+	local creation_timestamp, sum, count
+	local buckets = {}
+	local prev_bucket_count = 0
+	for suffix, extra_labels, value in metric:iter_samples() do
+		if suffix == "_created" then
+			creation_timestamp = value
+		elseif suffix == "_sum" then
+			sum = value
+		elseif suffix == "_count" then
+			count = value
+		elseif extra_labels then
+			local bucket_threshold = extra_labels["le"]
+			local bucket_count
+			if cumulative then
+				bucket_count = value
+			else
+				bucket_count = value - prev_bucket_count
+				prev_bucket_count = value
+			end
+			if bucket_threshold == "+Inf" then
+				t_insert(buckets, {threshold = 1/0, count = bucket_count})
+			elseif bucket_threshold ~= nil then
+				t_insert(buckets, {threshold = tonumber(bucket_threshold), count = bucket_count})
+			end
+		end
+	end
+
+	if #buckets == 0 or not creation_timestamp or not sum or not count then
+		print("[no data or not a histogram]")
+		return false
+	end
+
+	local graph_width, graph_height, wscale = #buckets, 10, 1;
+	if graph_width < 8 then
+		wscale = 8
+	elseif graph_width < 16 then
+		wscale = 4
+	elseif graph_width < 32 then
+		wscale = 2
+	end
+	local eighth_chars = "   ▁▂▃▄▅▆▇█";
+
+	local max_bin_samples = 0
+	for _, bucket in ipairs(buckets) do
+		if bucket.count > max_bin_samples then
+			max_bin_samples = bucket.count
+		end
+	end
+
+	print("");
+	print(prefix)
+	print(("_"):rep(graph_width*wscale).." "..max_bin_samples);
+	for row = graph_height, 1, -1 do
+		local row_chars = {};
+		local min_eighths, max_eighths = 8, 0;
+		for i = 1, #buckets do
+			local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/buckets[i].count))-(row-1), 1), 0)*8);
+			if char_eighths < min_eighths then
+				min_eighths = char_eighths;
+			end
+			if char_eighths > max_eighths then
+				max_eighths = char_eighths;
+			end
+			if char_eighths == 0 then
+				row_chars[i] = ("-"):rep(wscale);
+			else
+				local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
+				row_chars[i] = char:rep(wscale);
+			end
+		end
+		print(table.concat(row_chars).."|- "..string.format("%.8g", math.ceil((max_bin_samples/graph_height)*(row-0.5))));
+	end
+
+	local legend_pat = string.format("%%%d.%dg", wscale-1, wscale-1)
+	local row = {}
+	for i = 1, #buckets do
+		local threshold = buckets[i].threshold
+		t_insert(row, legend_pat:format(threshold))
+	end
+	t_insert(row, " " .. metric_family.unit)
+	print(t_concat(row, "/"))
+
+	return true
+end
+
+function stats_methods:render_single_fancy_histogram(print, prefix, metric_family, metric)
+	return self:render_single_fancy_histogram_ex(print, prefix, metric_family, metric, false)
+end
+
+function stats_methods:render_single_fancy_histogram_cf(print, prefix, metric_family, metric)
+	-- cf = cumulative frequency
+	return self:render_single_fancy_histogram_ex(print, prefix, metric_family, metric, true)
+end
+
+function stats_methods:cfgraph()
+	for _, stat_info in ipairs(self) do
+		local family_name, metric_family = unpack(stat_info, 1, 2)
+		local function print(s)
+			table.insert(stat_info.output, s);
+		end
+
+		if not self:render_family(print, family_name, metric_family, self.render_single_fancy_histogram_cf) then
+			return self
+		end
+	end
+	return self;
+end
+
+function stats_methods:histogram()
+	for _, stat_info in ipairs(self) do
+		local family_name, metric_family = unpack(stat_info, 1, 2)
+		local function print(s)
+			table.insert(stat_info.output, s);
+		end
+
+		if not self:render_family(print, family_name, metric_family, self.render_single_fancy_histogram) then
+			return self
+		end
+	end
+	return self;
+end
+
+function stats_methods:render_single_counter(print, prefix, metric_family, metric)
+	local created_timestamp, current_value
+	for suffix, _, value in metric:iter_samples() do
+		if suffix == "_created" then
+			created_timestamp = value
+		elseif suffix == "_total" then
+			current_value = value
+		end
+	end
+	if current_value and created_timestamp then
+		local base_unit = short_units[metric_family.unit] or metric_family.unit
+		local unit = base_unit .. "/s"
+		local factor = 1
+		if base_unit == "s" then
+			-- be smart!
+			unit = "%"
+			factor = 100
+		elseif base_unit == "" then
+			unit = "events/s"
+		end
+		print(("%-50s %s"):format(prefix, format_number(factor * current_value / (self.now - created_timestamp), unit.." [avg]")));
+	end
+end
+
+function stats_methods:render_single_gauge(print, prefix, metric_family, metric)
+	local current_value
+	for _, _, value in metric:iter_samples() do
+		current_value = value
+	end
+	if current_value then
+		local unit = short_units[metric_family.unit] or metric_family.unit
+		print(("%-50s %s"):format(prefix, format_number(current_value, unit)));
+	end
+end
+
+function stats_methods:render_single_summary(print, prefix, metric_family, metric)
+	local sum, count
+	for suffix, _, value in metric:iter_samples() do
+		if suffix == "_sum" then
+			sum = value
+		elseif suffix == "_count" then
+			count = value
+		end
+	end
+	if sum and count then
+		local unit = short_units[metric_family.unit] or metric_family.unit
+		if count == 0 then
+			print(("%-50s %s"):format(prefix, "no obs."));
+		else
+			print(("%-50s %s"):format(prefix, format_number(sum / count, unit.."/event [avg]")));
+		end
+	end
+end
+
+function stats_methods:render_family(print, family_name, metric_family, render_func)
+	local labelkeys = metric_family.label_keys
+	if #labelkeys > 0 then
+		print(family_name)
+		for labelset, metric in metric_family:iter_metrics() do
+			local labels = {}
+			for i, k in ipairs(labelkeys) do
+				local v = labelset[i]
+				t_insert(labels, ("%s=%s"):format(k, v))
+			end
+			local prefix = "  "..t_concat(labels, " ")
+			render_func(self, print, prefix, metric_family, metric)
+		end
+	else
+		for _, metric in metric_family:iter_metrics() do
+			render_func(self, print, family_name, metric_family, metric)
+		end
+	end
+end
+
+local function stats_tostring(stats)
+	local print = stats.session.print;
+	for _, stat_info in ipairs(stats) do
+		if #stat_info.output > 0 then
+			print("\n#"..stat_info[1]);
+			print("");
+			for _, v in ipairs(stat_info.output) do
+				print(v);
+			end
+			print("");
+		else
+			local metric_family = stat_info[2]
+			if metric_family.type_ == "counter" then
+				stats:render_family(print, stat_info[1], metric_family, stats.render_single_counter)
+			elseif metric_family.type_ == "gauge" or metric_family.type_ == "unknown" then
+				stats:render_family(print, stat_info[1], metric_family, stats.render_single_gauge)
+			elseif metric_family.type_ == "summary" or metric_family.type_ == "histogram" then
+				stats:render_family(print, stat_info[1], metric_family, stats.render_single_summary)
+			end
+		end
+	end
+	return #stats.." statistics displayed";
+end
+
+local stats_mt = {__index = stats_methods, __tostring = stats_tostring }
+local function new_stats_context(self)
+	-- TODO: instead of now(), it might be better to take the time of the last
+	-- interval, if the statistics backend is set to use periodic collection
+	-- Otherwise we get strange stuff like average cpu usage decreasing until
+	-- the next sample and so on.
+	return setmetatable({ session = self.session, stats = true, now = time.now() }, stats_mt);
+end
+
+function def_env.stats:show(name_filter)
+	local statsman = require "core.statsmanager"
+	local collect = statsman.collect
+	if collect then
+		-- force collection if in manual mode
+		collect()
+	end
+	local metric_registry = statsman.get_metric_registry();
+	local displayed_stats = new_stats_context(self);
+	for family_name, metric_family in iterators.sorted_pairs(metric_registry:get_metric_families()) do
+		if not name_filter or family_name:match(name_filter) then
+			table.insert(displayed_stats, {
+				family_name,
+				metric_family,
+				output = {}
+			})
+		end
+	end
+	return displayed_stats;
+end
+
+
+
+-------------
+
+function printbanner(session)
+	local option = module:get_option_string("console_banner", "full");
+	if option == "full" or option == "graphic" then
+		session.print [[
+                   ____                \   /     _
+                    |  _ \ _ __ ___  ___  _-_   __| |_   _
+                    | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
+                    |  __/| | | (_) \__ \ |_| | (_| | |_| |
+                    |_|   |_|  \___/|___/\___/ \__,_|\__, |
+                    A study in simplicity            |___/
+
+]]
+	end
+	if option == "short" or option == "full" then
+	session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
+	session.print("You may find more help on using this console in our online documentation at ");
+	session.print("https://prosody.im/doc/console\n");
+	end
+	if option ~= "short" and option ~= "full" and option ~= "graphic" then
+		session.print(option);
+	end
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_admin_socket.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,90 @@
+module:set_global();
+
+local have_unix, unix = pcall(require, "socket.unix");
+
+if have_unix and type(unix) == "function" then
+	-- COMPAT #1717
+	-- Before the introduction of datagram support, only the stream socket
+	-- constructor was exported instead of a module table. Due to the lack of a
+	-- proper release of LuaSocket, distros have settled on shipping either the
+	-- last RC tag or some commit since then.
+	-- Here we accomodate both variants.
+	unix = { stream = unix };
+end
+if not have_unix or type(unix) ~= "table" then
+	module:log_status("error", "LuaSocket unix socket support not available or incompatible, ensure it is up to date");
+	return;
+end
+
+local server = require "net.server";
+
+local adminstream = require "util.adminstream";
+
+local socket_path = module:get_option_path("admin_socket", "prosody.sock", "data");
+
+local sessions = module:shared("sessions");
+
+local function fire_admin_event(session, stanza)
+	local event_data = {
+		origin = session, stanza = stanza;
+	};
+	local event_name;
+	if stanza.attr.xmlns then
+		event_name = "admin/"..stanza.attr.xmlns..":"..stanza.name;
+	else
+		event_name = "admin/"..stanza.name;
+	end
+	module:log("debug", "Firing %s", event_name);
+	return module:fire_event(event_name, event_data);
+end
+
+module:hook("server-stopping", function ()
+	for _, session in pairs(sessions) do
+		session:close("system-shutdown");
+	end
+	os.remove(socket_path);
+end);
+
+--- Unix domain socket management
+
+local conn, sock;
+
+local listeners = adminstream.server(sessions, fire_admin_event).listeners;
+
+local function accept_connection()
+	module:log("debug", "accepting...");
+	local client = sock:accept();
+	if not client then return; end
+	server.wrapclient(client, "unix", 0, listeners, "*a");
+end
+
+function module.load()
+	sock = unix.stream();
+	sock:settimeout(0);
+	os.remove(socket_path);
+	local ok, err = sock:bind(socket_path);
+	if not ok then
+		module:log_status("error", "Unable to bind admin socket %s: %s", socket_path, err);
+		return;
+	end
+	local ok, err = sock:listen();
+	if not ok then
+		module:log_status("error", "Unable to listen on admin socket %s: %s", socket_path, err);
+		return;
+	end
+	if server.wrapserver then
+		conn = server.wrapserver(sock, socket_path, 0, listeners);
+	else
+		conn = server.watchfd(sock:getfd(), accept_connection);
+	end
+end
+
+function module.unload()
+	if conn then
+		conn:close();
+	end
+	if sock then
+		sock:close();
+	end
+	os.remove(socket_path);
+end
--- a/plugins/mod_admin_telnet.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_admin_telnet.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,49 +8,68 @@
 -- luacheck: ignore 212/self
 
 module:set_global();
-
-local hostmanager = require "core.hostmanager";
-local modulemanager = require "core.modulemanager";
-local s2smanager = require "core.s2smanager";
-local portmanager = require "core.portmanager";
-local helpers = require "util.helpers";
-local server = require "net.server";
-
-local _G = _G;
-
-local prosody = _G.prosody;
+module:depends("admin_shell");
 
 local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" };
 
-local iterators = require "util.iterators";
-local keys, values = iterators.keys, iterators.values;
-local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join");
-local set, array = require "util.set", require "util.array";
-local cert_verify_identity = require "util.x509".verify_identity;
-local envload = require "util.envload".envload;
-local envloadfile = require "util.envload".envloadfile;
-local has_pposix, pposix = pcall(require, "util.pposix");
+local async = require "util.async";
+local st = require "util.stanza";
 
-local commands = module:shared("commands")
-local def_env = module:shared("env");
+local def_env = module:shared("admin_shell/env");
 local default_env_mt = { __index = def_env };
 
-local function redirect_output(target, session)
-	local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
-	env.dofile = function(name)
-		local f, err = envloadfile(name, env);
-		if not f then return f, err; end
-		return f();
-	end;
-	return env;
+local function printbanner(session)
+	local option = module:get_option_string("console_banner", "full");
+	if option == "full" or option == "graphic" then
+		session.print [[
+                   ____                \   /     _
+                    |  _ \ _ __ ___  ___  _-_   __| |_   _
+                    | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
+                    |  __/| | | (_) \__ \ |_| | (_| | |_| |
+                    |_|   |_|  \___/|___/\___/ \__,_|\__, |
+                    A study in simplicity            |___/
+
+]]
+	end
+	if option == "short" or option == "full" then
+	session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
+	session.print("You may find more help on using this console in our online documentation at ");
+	session.print("https://prosody.im/doc/console\n");
+	end
+	if option ~= "short" and option ~= "full" and option ~= "graphic" then
+		session.print(option);
+	end
 end
 
 console = {};
 
+local runner_callbacks = {};
+
+function runner_callbacks:ready()
+	self.data.conn:resume();
+end
+
+function runner_callbacks:waiting()
+	self.data.conn:pause();
+end
+
+function runner_callbacks:error(err)
+	module:log("error", "Traceback[telnet]: %s", err);
+
+	self.data.print("Fatal error while running command, it did not complete");
+	self.data.print("Error: "..tostring(err));
+end
+
+
 function console:new_session(conn)
 	local w = function(s) conn:write(s:gsub("\n", "\r\n")); end;
 	local session = { conn = conn;
-			send = function (t) w(tostring(t)); end;
+			send = function (t)
+				if st.is_stanza(t) and (t.name == "repl-result" or t.name == "repl-output") then
+					t = "| "..t:get_text().."\n";
+				end
+				w(tostring(t));
+			end;
 			print = function (...)
 				local t = {};
 				for i=1,select("#", ...) do
@@ -58,10 +77,16 @@
 				end
 				w("| "..table.concat(t, "\t").."\n");
 			end;
+			serialize = tostring;
 			disconnect = function () conn:close(); end;
 			};
 	session.env = setmetatable({}, default_env_mt);
 
+	session.thread = async.runner(function (line)
+		console:process_line(session, line);
+		session.send(string.char(0));
+	end, runner_callbacks, session);
+
 	-- Load up environment with helper objects
 	for name, t in pairs(def_env) do
 		if type(t) == "table" then
@@ -69,69 +94,37 @@
 		end
 	end
 
+	session.env.output:configure();
+
 	return session;
 end
 
 function console:process_line(session, line)
-	local useglobalenv;
-
-	if line:match("^>") then
-		line = line:gsub("^>", "");
-		useglobalenv = true;
-	elseif line == "\004" then
-		commands["bye"](session, line);
-		return;
-	else
-		local command = line:match("^%w+") or line:match("%p");
-		if commands[command] then
-			commands[command](session, line);
-			return;
-		end
-	end
-
-	session.env._ = line;
-
-	local chunkname = "=console";
-	local env = (useglobalenv and redirect_output(_G, session)) or session.env or nil
-	local chunk, err = envload("return "..line, chunkname, env);
-	if not chunk then
-		chunk, err = envload(line, chunkname, env);
-		if not chunk then
-			err = err:gsub("^%[string .-%]:%d+: ", "");
-			err = err:gsub("^:%d+: ", "");
-			err = err:gsub("'<eof>'", "the end of the line");
-			session.print("Sorry, I couldn't understand that... "..err);
-			return;
-		end
-	end
-
-	local ranok, taskok, message = pcall(chunk);
-
-	if not (ranok or message or useglobalenv) and commands[line:lower()] then
-		commands[line:lower()](session, line);
+	line = line:gsub("\r?\n$", "");
+	if line == "bye" or line == "quit" or line == "exit" or line:byte() == 4 then
+		session.print("See you!");
+		session:disconnect();
 		return;
 	end
-
-	if not ranok then
-		session.print("Fatal error while running command, it did not complete");
-		session.print("Error: "..taskok);
-		return;
-	end
-
-	if not message then
-		session.print("Result: "..tostring(taskok));
-		return;
-	elseif (not taskok) and message then
-		session.print("Command completed with a problem");
-		session.print("Message: "..tostring(message));
-		return;
-	end
-
-	session.print("OK: "..tostring(message));
+	return module:fire_event("admin/repl-input", { origin = session, stanza = st.stanza("repl-input"):text(line) });
 end
 
 local sessions = {};
 
+function module.save()
+	return { sessions = sessions }
+end
+
+function module.restore(data)
+	if data.sessions then
+		for conn in pairs(data.sessions) do
+			conn:setlistener(console_listener);
+			local session = console:new_session(conn);
+			sessions[conn] = session;
+		end
+	end
+end
+
 function console_listener.onconnect(conn)
 	-- Handle new connection
 	local session = console:new_session(conn);
@@ -150,8 +143,7 @@
 
 	for line in data:gmatch("[^\n]*[\n\004]") do
 		if session.closed then return end
-		console:process_line(session, line);
-		session.send(string.char(0));
+		session.thread:run(line);
 	end
 	session.partial_data = data:match("[^\n]+$");
 end
@@ -176,1382 +168,6 @@
 	sessions[conn] = nil;
 end
 
--- Console commands --
--- These are simple commands, not valid standalone in Lua
-
-function commands.bye(session)
-	session.print("See you! :)");
-	session.closed = true;
-	session.disconnect();
-end
-commands.quit, commands.exit = commands.bye, commands.bye;
-
-commands["!"] = function (session, data)
-	if data:match("^!!") and session.env._ then
-		session.print("!> "..session.env._);
-		return console_listener.onincoming(session.conn, session.env._);
-	end
-	local old, new = data:match("^!(.-[^\\])!(.-)!$");
-	if old and new then
-		local ok, res = pcall(string.gsub, session.env._, old, new);
-		if not ok then
-			session.print(res)
-			return;
-		end
-		session.print("!> "..res);
-		return console_listener.onincoming(session.conn, res);
-	end
-	session.print("Sorry, not sure what you want");
-end
-
-
-function commands.help(session, data)
-	local print = session.print;
-	local section = data:match("^help (%w+)");
-	if not section then
-		print [[Commands are divided into multiple sections. For help on a particular section, ]]
-		print [[type: help SECTION (for example, 'help c2s'). Sections are: ]]
-		print [[]]
-		print [[c2s - Commands to manage local client-to-server sessions]]
-		print [[s2s - Commands to manage sessions between this server and others]]
-		print [[module - Commands to load/reload/unload modules/plugins]]
-		print [[host - Commands to activate, deactivate and list virtual hosts]]
-		print [[user - Commands to create and delete users, and change their passwords]]
-		print [[server - Uptime, version, shutting down, etc.]]
-		print [[port - Commands to manage ports the server is listening on]]
-		print [[dns - Commands to manage and inspect the internal DNS resolver]]
-		print [[config - Reloading the configuration, etc.]]
-		print [[console - Help regarding the console itself]]
-	elseif section == "c2s" then
-		print [[c2s:show(jid) - Show all client sessions with the specified JID (or all if no JID given)]]
-		print [[c2s:show_insecure() - Show all unencrypted client connections]]
-		print [[c2s:show_secure() - Show all encrypted client connections]]
-		print [[c2s:show_tls() - Show TLS cipher info for encrypted sessions]]
-		print [[c2s:close(jid) - Close all sessions for the specified JID]]
-	elseif section == "s2s" then
-		print [[s2s:show(domain) - Show all s2s connections for the given domain (or all if no domain given)]]
-		print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
-		print [[s2s:close(from, to) - Close a connection from one domain to another]]
-		print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
-	elseif section == "module" then
-		print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
-		print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
-		print [[module:unload(module, host) - The same, but just unloads the module from memory]]
-		print [[module:list(host) - List the modules loaded on the specified host]]
-	elseif section == "host" then
-		print [[host:activate(hostname) - Activates the specified host]]
-		print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
-		print [[host:list() - List the currently-activated hosts]]
-	elseif section == "user" then
-		print [[user:create(jid, password) - Create the specified user account]]
-		print [[user:password(jid, password) - Set the password for the specified user account]]
-		print [[user:delete(jid) - Permanently remove the specified user account]]
-		print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
-	elseif section == "server" then
-		print [[server:version() - Show the server's version number]]
-		print [[server:uptime() - Show how long the server has been running]]
-		print [[server:memory() - Show details about the server's memory usage]]
-		print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
-	elseif section == "port" then
-		print [[port:list() - Lists all network ports prosody currently listens on]]
-		print [[port:close(port, interface) - Close a port]]
-	elseif section == "dns" then
-		print [[dns:lookup(name, type, class) - Do a DNS lookup]]
-		print [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
-		print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
-		print [[dns:purge() - Clear the DNS cache]]
-		print [[dns:cache() - Show cached records]]
-	elseif section == "config" then
-		print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
-	elseif section == "console" then
-		print [[Hey! Welcome to Prosody's admin console.]]
-		print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]]
-		print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]]
-		print [[so you may have trouble using the arrow keys, etc. depending on your system.]]
-		print [[]]
-		print [[For now we offer a couple of handy shortcuts:]]
-		print [[!! - Repeat the last command]]
-		print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']]
-		print [[]]
-		print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]]
-		print [[you can prefix a command with > to escape the console sandbox, and access everything in]]
-		print [[the running server. Great fun, but be careful not to break anything :)]]
-	end
-	print [[]]
-end
-
--- Session environment --
--- Anything in def_env will be accessible within the session as a global variable
-
---luacheck: ignore 212/self
-
-def_env.server = {};
-
-function def_env.server:insane_reload()
-	prosody.unlock_globals();
-	dofile "prosody"
-	prosody = _G.prosody;
-	return true, "Server reloaded";
-end
-
-function def_env.server:version()
-	return true, tostring(prosody.version or "unknown");
-end
-
-function def_env.server:uptime()
-	local t = os.time()-prosody.start_time;
-	local seconds = t%60;
-	t = (t - seconds)/60;
-	local minutes = t%60;
-	t = (t - minutes)/60;
-	local hours = t%24;
-	t = (t - hours)/24;
-	local days = t;
-	return true, string.format("This server has been running for %d day%s, %d hour%s and %d minute%s (since %s)",
-		days, (days ~= 1 and "s") or "", hours, (hours ~= 1 and "s") or "",
-		minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
-end
-
-function def_env.server:shutdown(reason)
-	prosody.shutdown(reason);
-	return true, "Shutdown initiated";
-end
-
-local function human(kb)
-	local unit = "K";
-	if kb > 1024 then
-		kb, unit = kb/1024, "M";
-	end
-	return ("%0.2f%sB"):format(kb, unit);
-end
-
-function def_env.server:memory()
-	if not has_pposix or not pposix.meminfo then
-		return true, "Lua is using "..human(collectgarbage("count"));
-	end
-	local mem, lua_mem = pposix.meminfo(), collectgarbage("count");
-	local print = self.session.print;
-	print("Process: "..human((mem.allocated+mem.allocated_mmap)/1024));
-	print("   Used: "..human(mem.used/1024).." ("..human(lua_mem).." by Lua)");
-	print("   Free: "..human(mem.unused/1024).." ("..human(mem.returnable/1024).." returnable)");
-	return true, "OK";
-end
-
-def_env.module = {};
-
-local function get_hosts_set(hosts, module)
-	if type(hosts) == "table" then
-		if hosts[1] then
-			return set.new(hosts);
-		elseif hosts._items then
-			return hosts;
-		end
-	elseif type(hosts) == "string" then
-		return set.new { hosts };
-	elseif hosts == nil then
-		local hosts_set = set.new(array.collect(keys(prosody.hosts)))
-			/ function (host) return (prosody.hosts[host].type == "local" or module and modulemanager.is_loaded(host, module)) and host or nil; end;
-		if module and modulemanager.get_module("*", module) then
-			hosts_set:add("*");
-		end
-		return hosts_set;
-	end
-end
-
-function def_env.module:load(name, hosts, config)
-	hosts = get_hosts_set(hosts);
-
-	-- Load the module for each host
-	local ok, err, count, mod = true, nil, 0;
-	for host in hosts do
-		if (not modulemanager.is_loaded(host, name)) then
-			mod, err = modulemanager.load(host, name, config);
-			if not mod then
-				ok = false;
-				if err == "global-module-already-loaded" then
-					if count > 0 then
-						ok, err, count = true, nil, 1;
-					end
-					break;
-				end
-				self.session.print(err or "Unknown error loading module");
-			else
-				count = count + 1;
-				self.session.print("Loaded for "..mod.module.host);
-			end
-		end
-	end
-
-	return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
-end
-
-function def_env.module:unload(name, hosts)
-	hosts = get_hosts_set(hosts, name);
-
-	-- Unload the module for each host
-	local ok, err, count = true, nil, 0;
-	for host in hosts do
-		if modulemanager.is_loaded(host, name) then
-			ok, err = modulemanager.unload(host, name);
-			if not ok then
-				ok = false;
-				self.session.print(err or "Unknown error unloading module");
-			else
-				count = count + 1;
-				self.session.print("Unloaded from "..host);
-			end
-		end
-	end
-	return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
-end
-
-local function _sort_hosts(a, b)
-	if a == "*" then return true
-	elseif b == "*" then return false
-	else return a < b; end
-end
-
-function def_env.module:reload(name, hosts)
-	hosts = array.collect(get_hosts_set(hosts, name)):sort(_sort_hosts)
-
-	-- Reload the module for each host
-	local ok, err, count = true, nil, 0;
-	for _, host in ipairs(hosts) do
-		if modulemanager.is_loaded(host, name) then
-			ok, err = modulemanager.reload(host, name);
-			if not ok then
-				ok = false;
-				self.session.print(err or "Unknown error reloading module");
-			else
-				count = count + 1;
-				if ok == nil then
-					ok = true;
-				end
-				self.session.print("Reloaded on "..host);
-			end
-		end
-	end
-	return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
-end
-
-function def_env.module:list(hosts)
-	if hosts == nil then
-		hosts = array.collect(keys(prosody.hosts));
-		table.insert(hosts, 1, "*");
-	end
-	if type(hosts) == "string" then
-		hosts = { hosts };
-	end
-	if type(hosts) ~= "table" then
-		return false, "Please supply a host or a list of hosts you would like to see";
-	end
-
-	local print = self.session.print;
-	for _, host in ipairs(hosts) do
-		print((host == "*" and "Global" or host)..":");
-		local modules = array.collect(keys(modulemanager.get_modules(host) or {})):sort();
-		if #modules == 0 then
-			if prosody.hosts[host] then
-				print("    No modules loaded");
-			else
-				print("    Host not found");
-			end
-		else
-			for _, name in ipairs(modules) do
-				print("    "..name);
-			end
-		end
-	end
-end
-
-def_env.config = {};
-function def_env.config:load(filename, format)
-	local config_load = require "core.configmanager".load;
-	local ok, err = config_load(filename, format);
-	if not ok then
-		return false, err or "Unknown error loading config";
-	end
-	return true, "Config loaded";
-end
-
-function def_env.config:get(host, section, key)
-	local config_get = require "core.configmanager".get
-	return true, tostring(config_get(host, section, key));
-end
-
-function def_env.config:reload()
-	local ok, err = prosody.reload_config();
-	return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
-end
-
-local function common_info(session, line)
-	if session.id then
-		line[#line+1] = "["..session.id.."]"
-	else
-		line[#line+1] = "["..session.type..(tostring(session):match("%x*$")).."]"
-	end
-end
-
-local function session_flags(session, line)
-	line = line or {};
-	common_info(session, line);
-	if session.type == "c2s" then
-		local status, priority = "unavailable", tostring(session.priority or "-");
-		if session.presence then
-			status = session.presence:get_child_text("show") or "available";
-		end
-		line[#line+1] = status.."("..priority..")";
-	end
-	if session.cert_identity_status == "valid" then
-		line[#line+1] = "(authenticated)";
-	end
-	if session.secure then
-		line[#line+1] = "(encrypted)";
-	end
-	if session.compressed then
-		line[#line+1] = "(compressed)";
-	end
-	if session.smacks then
-		line[#line+1] = "(sm)";
-	end
-	if session.ip and session.ip:match(":") then
-		line[#line+1] = "(IPv6)";
-	end
-	if session.remote then
-		line[#line+1] = "(remote)";
-	end
-	return table.concat(line, " ");
-end
-
-local function tls_info(session, line)
-	line = line or {};
-	common_info(session, line);
-	if session.secure then
-		local sock = session.conn and session.conn.socket and session.conn:socket();
-		if sock then
-			local info = sock.info and sock:info();
-			if info then
-				line[#line+1] = ("(%s with %s)"):format(info.protocol, info.cipher);
-			else
-				-- TLS session might not be ready yet
-				line[#line+1] = "(cipher info unavailable)";
-			end
-		end
-	else
-		line[#line+1] = "(insecure)";
-	end
-	return table.concat(line, " ");
-end
-
-def_env.c2s = {};
-
-local function get_jid(session)
-	if session.username then
-		return session.full_jid or jid_join(session.username, session.host, session.resource);
-	end
-
-	local conn = session.conn;
-	local ip = session.ip or "?";
-	local clientport = conn and conn:clientport() or "?";
-	local serverip = conn and conn.server and conn:server():ip() or "?";
-	local serverport = conn and conn:serverport() or "?"
-	return jid_join("["..ip.."]:"..clientport, session.host or "["..serverip.."]:"..serverport);
-end
-
-local function show_c2s(callback)
-	local c2s = array.collect(values(module:shared"/*/c2s/sessions"));
-	c2s:sort(function(a, b)
-		if a.host == b.host then
-			if a.username == b.username then
-				return (a.resource or "") > (b.resource or "");
-			end
-			return (a.username or "") > (b.username or "");
-		end
-		return (a.host or "") > (b.host or "");
-	end):map(function (session)
-		callback(get_jid(session), session)
-	end);
-end
-
-function def_env.c2s:count()
-	return true, "Total: "..  iterators.count(values(module:shared"/*/c2s/sessions")) .." clients";
-end
-
-function def_env.c2s:show(match_jid, annotate)
-	local print, count = self.session.print, 0;
-	annotate = annotate or session_flags;
-	local curr_host = false;
-	show_c2s(function (jid, session)
-		if curr_host ~= session.host then
-			curr_host = session.host;
-			print(curr_host or "(not connected to any host yet)");
-		end
-		if (not match_jid) or jid:match(match_jid) then
-			count = count + 1;
-			print(annotate(session, { "  ", jid }));
-		end
-	end);
-	return true, "Total: "..count.." clients";
-end
-
-function def_env.c2s:show_insecure(match_jid)
-	local print, count = self.session.print, 0;
-	show_c2s(function (jid, session)
-		if ((not match_jid) or jid:match(match_jid)) and not session.secure then
-			count = count + 1;
-			print(jid);
-		end
-	end);
-	return true, "Total: "..count.." insecure client connections";
-end
-
-function def_env.c2s:show_secure(match_jid)
-	local print, count = self.session.print, 0;
-	show_c2s(function (jid, session)
-		if ((not match_jid) or jid:match(match_jid)) and session.secure then
-			count = count + 1;
-			print(jid);
-		end
-	end);
-	return true, "Total: "..count.." secure client connections";
-end
-
-function def_env.c2s:show_tls(match_jid)
-	return self:show(match_jid, tls_info);
-end
-
-function def_env.c2s:close(match_jid)
-	local count = 0;
-	show_c2s(function (jid, session)
-		if jid == match_jid or jid_bare(jid) == match_jid then
-			count = count + 1;
-			session:close();
-		end
-	end);
-	return true, "Total: "..count.." sessions closed";
-end
-
-
-def_env.s2s = {};
-function def_env.s2s:show(match_jid, annotate)
-	local print = self.session.print;
-	annotate = annotate or session_flags;
-
-	local count_in, count_out = 0,0;
-	local s2s_list = { };
-
-	local s2s_sessions = module:shared"/*/s2s/sessions";
-	for _, session in pairs(s2s_sessions) do
-		local remotehost, localhost, direction;
-		if session.direction == "outgoing" then
-			direction = "->";
-			count_out = count_out + 1;
-			remotehost, localhost = session.to_host or "?", session.from_host or "?";
-		else
-			direction = "<-";
-			count_in = count_in + 1;
-			remotehost, localhost = session.from_host or "?", session.to_host or "?";
-		end
-		local sess_lines = { l = localhost, r = remotehost,
-			annotate(session, { "", direction, remotehost or "?" })};
-
-		if (not match_jid) or remotehost:match(match_jid) or localhost:match(match_jid) then
-			table.insert(s2s_list, sess_lines);
-			-- luacheck: ignore 421/print
-			local print = function (s) table.insert(sess_lines, "        "..s); end
-			if session.sendq then
-				print("There are "..#session.sendq.." queued outgoing stanzas for this connection");
-			end
-			if session.type == "s2sout_unauthed" then
-				if session.connecting then
-					print("Connection not yet established");
-					if not session.srv_hosts then
-						if not session.conn then
-							print("We do not yet have a DNS answer for this host's SRV records");
-						else
-							print("This host has no SRV records, using A record instead");
-						end
-					elseif session.srv_choice then
-						print("We are on SRV record "..session.srv_choice.." of "..#session.srv_hosts);
-						local srv_choice = session.srv_hosts[session.srv_choice];
-						print("Using "..(srv_choice.target or ".")..":"..(srv_choice.port or 5269));
-					end
-				elseif session.notopen then
-					print("The <stream> has not yet been opened");
-				elseif not session.dialback_key then
-					print("Dialback has not been initiated yet");
-				elseif session.dialback_key then
-					print("Dialback has been requested, but no result received");
-				end
-			end
-			if session.type == "s2sin_unauthed" then
-				print("Connection not yet authenticated");
-			elseif session.type == "s2sin" then
-				for name in pairs(session.hosts) do
-					if name ~= session.from_host then
-						print("also hosts "..tostring(name));
-					end
-				end
-			end
-		end
-	end
-
-	-- Sort by local host, then remote host
-	table.sort(s2s_list, function(a,b)
-		if a.l == b.l then return a.r < b.r; end
-		return a.l < b.l;
-	end);
-	local lasthost;
-	for _, sess_lines in ipairs(s2s_list) do
-		if sess_lines.l ~= lasthost then print(sess_lines.l); lasthost=sess_lines.l end
-		for _, line in ipairs(sess_lines) do print(line); end
-	end
-	return true, "Total: "..count_out.." outgoing, "..count_in.." incoming connections";
-end
-
-function def_env.s2s:show_tls(match_jid)
-	return self:show(match_jid, tls_info);
-end
-
-local function print_subject(print, subject)
-	for _, entry in ipairs(subject) do
-		print(
-			("    %s: %q"):format(
-				entry.name or entry.oid,
-				entry.value:gsub("[\r\n%z%c]", " ")
-			)
-		);
-	end
-end
-
--- As much as it pains me to use the 0-based depths that OpenSSL does,
--- I think there's going to be more confusion among operators if we
--- break from that.
-local function print_errors(print, errors)
-	for depth, t in pairs(errors) do
-		print(
-			("    %d: %s"):format(
-				depth-1,
-				table.concat(t, "\n|        ")
-			)
-		);
-	end
-end
-
-function def_env.s2s:showcert(domain)
-	local print = self.session.print;
-	local s2s_sessions = module:shared"/*/s2s/sessions";
-	local domain_sessions = set.new(array.collect(values(s2s_sessions)))
-		/function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end;
-	local cert_set = {};
-	for session in domain_sessions do
-		local conn = session.conn;
-		conn = conn and conn:socket();
-		if not conn.getpeerchain then
-			if conn.dohandshake then
-				error("This version of LuaSec does not support certificate viewing");
-			end
-		else
-			local cert = conn:getpeercertificate();
-			if cert then
-				local certs = conn:getpeerchain();
-				local digest = cert:digest("sha1");
-				if not cert_set[digest] then
-					local chain_valid, chain_errors = conn:getpeerverification();
-					cert_set[digest] = {
-						{
-						  from = session.from_host,
-						  to = session.to_host,
-						  direction = session.direction
-						};
-						chain_valid = chain_valid;
-						chain_errors = chain_errors;
-						certs = certs;
-					};
-				else
-					table.insert(cert_set[digest], {
-						from = session.from_host,
-						to = session.to_host,
-						direction = session.direction
-					});
-				end
-			end
-		end
-	end
-	local domain_certs = array.collect(values(cert_set));
-	-- Phew. We now have a array of unique certificates presented by domain.
-	local n_certs = #domain_certs;
-
-	if n_certs == 0 then
-		return "No certificates found for "..domain;
-	end
-
-	local function _capitalize_and_colon(byte)
-		return string.upper(byte)..":";
-	end
-	local function pretty_fingerprint(hash)
-		return hash:gsub("..", _capitalize_and_colon):sub(1, -2);
-	end
-
-	for cert_info in values(domain_certs) do
-		local certs = cert_info.certs;
-		local cert = certs[1];
-		print("---")
-		print("Fingerprint (SHA1): "..pretty_fingerprint(cert:digest("sha1")));
-		print("");
-		local n_streams = #cert_info;
-		print("Currently used on "..n_streams.." stream"..(n_streams==1 and "" or "s")..":");
-		for _, stream in ipairs(cert_info) do
-			if stream.direction == "incoming" then
-				print("    "..stream.to.." <- "..stream.from);
-			else
-				print("    "..stream.from.." -> "..stream.to);
-			end
-		end
-		print("");
-		local chain_valid, errors = cert_info.chain_valid, cert_info.chain_errors;
-		local valid_identity = cert_verify_identity(domain, "xmpp-server", cert);
-		if chain_valid then
-			print("Trusted certificate: Yes");
-		else
-			print("Trusted certificate: No");
-			print_errors(print, errors);
-		end
-		print("");
-		print("Issuer: ");
-		print_subject(print, cert:issuer());
-		print("");
-		print("Valid for "..domain..": "..(valid_identity and "Yes" or "No"));
-		print("Subject:");
-		print_subject(print, cert:subject());
-	end
-	print("---");
-	return ("Showing "..n_certs.." certificate"
-		..(n_certs==1 and "" or "s")
-		.." presented by "..domain..".");
-end
-
-function def_env.s2s:close(from, to)
-	local print, count = self.session.print, 0;
-	local s2s_sessions = module:shared"/*/s2s/sessions";
-
-	local match_id;
-	if from and not to then
-		match_id, from = from, nil;
-	elseif not to then
-		return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'";
-	elseif from == to then
-		return false, "Both from and to are the same... you can't do that :)";
-	end
-
-	for _, session in pairs(s2s_sessions) do
-		local id = session.type..tostring(session):match("[a-f0-9]+$");
-		if (match_id and match_id == id)
-		or (session.from_host == from and session.to_host == to) then
-			print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id));
-			(session.close or s2smanager.destroy_session)(session);
-			count = count + 1 ;
-		end
-	end
-	return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s");
-end
-
-function def_env.s2s:closeall(host)
-	local count = 0;
-	local s2s_sessions = module:shared"/*/s2s/sessions";
-	for _,session in pairs(s2s_sessions) do
-		if not host or session.from_host == host or session.to_host == host then
-			session:close();
-			count = count + 1;
-		end
-	end
-	if count == 0 then return false, "No sessions to close.";
-	else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end
-end
-
-def_env.host = {}; def_env.hosts = def_env.host;
-
-function def_env.host:activate(hostname, config)
-	return hostmanager.activate(hostname, config);
-end
-function def_env.host:deactivate(hostname, reason)
-	return hostmanager.deactivate(hostname, reason);
-end
-
-function def_env.host:list()
-	local print = self.session.print;
-	local i = 0;
-	local type;
-	for host, host_session in iterators.sorted_pairs(prosody.hosts) do
-		i = i + 1;
-		type = host_session.type;
-		if type == "local" then
-			print(host);
-		else
-			type = module:context(host):get_option_string("component_module", type);
-			if type ~= "component" then
-				type = type .. " component";
-			end
-			print(("%s (%s)"):format(host, type));
-		end
-	end
-	return true, i.." hosts";
-end
-
-def_env.port = {};
-
-function def_env.port:list()
-	local print = self.session.print;
-	local services = portmanager.get_active_services().data;
-	local n_services, n_ports = 0, 0;
-	for service, interfaces in iterators.sorted_pairs(services) do
-		n_services = n_services + 1;
-		local ports_list = {};
-		for interface, ports in pairs(interfaces) do
-			for port in pairs(ports) do
-				table.insert(ports_list, "["..interface.."]:"..port);
-			end
-		end
-		n_ports = n_ports + #ports_list;
-		print(service..": "..table.concat(ports_list, ", "));
-	end
-	return true, n_services.." services listening on "..n_ports.." ports";
-end
-
-function def_env.port:close(close_port, close_interface)
-	close_port = assert(tonumber(close_port), "Invalid port number");
-	local n_closed = 0;
-	local services = portmanager.get_active_services().data;
-	for service, interfaces in pairs(services) do -- luacheck: ignore 213
-		for interface, ports in pairs(interfaces) do
-			if not close_interface or close_interface == interface then
-				if ports[close_port] then
-					self.session.print("Closing ["..interface.."]:"..close_port.."...");
-					local ok, err = portmanager.close(interface, close_port)
-					if not ok then
-						self.session.print("Failed to close "..interface.." "..close_port..": "..err);
-					else
-						n_closed = n_closed + 1;
-					end
-				end
-			end
-		end
-	end
-	return true, "Closed "..n_closed.." ports";
-end
-
-def_env.muc = {};
-
-local console_room_mt = {
-	__index = function (self, k) return self.room[k]; end;
-	__tostring = function (self)
-		return "MUC room <"..self.room.jid..">";
-	end;
-};
-
-local function check_muc(jid)
-	local room_name, host = jid_split(jid);
-	if not prosody.hosts[host] then
-		return nil, "No such host: "..host;
-	elseif not prosody.hosts[host].modules.muc then
-		return nil, "Host '"..host.."' is not a MUC service";
-	end
-	return room_name, host;
-end
-
-function def_env.muc:create(room_jid, config)
-	local room_name, host = check_muc(room_jid);
-	if not room_name then
-		return room_name, host;
-	end
-	if not room_name then return nil, host end
-	if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end
-	if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end
-	return prosody.hosts[host].modules.muc.create_room(room_jid, config);
-end
-
-function def_env.muc:room(room_jid)
-	local room_name, host = check_muc(room_jid);
-	if not room_name then
-		return room_name, host;
-	end
-	local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
-	if not room_obj then
-		return nil, "No such room: "..room_jid;
-	end
-	return setmetatable({ room = room_obj }, console_room_mt);
-end
-
-function def_env.muc:list(host)
-	local host_session = prosody.hosts[host];
-	if not host_session or not host_session.modules.muc then
-		return nil, "Please supply the address of a local MUC component";
-	end
-	local print = self.session.print;
-	local c = 0;
-	for room in host_session.modules.muc.each_room() do
-		print(room.jid);
-		c = c + 1;
-	end
-	return true, c.." rooms";
-end
-
-local um = require"core.usermanager";
-
-def_env.user = {};
-function def_env.user:create(jid, password)
-	local username, host = jid_split(jid);
-	if not prosody.hosts[host] then
-		return nil, "No such host: "..host;
-	elseif um.user_exists(username, host) then
-		return nil, "User exists";
-	end
-	local ok, err = um.create_user(username, password, host);
-	if ok then
-		return true, "User created";
-	else
-		return nil, "Could not create user: "..err;
-	end
-end
-
-function def_env.user:delete(jid)
-	local username, host = jid_split(jid);
-	if not prosody.hosts[host] then
-		return nil, "No such host: "..host;
-	elseif not um.user_exists(username, host) then
-		return nil, "No such user";
-	end
-	local ok, err = um.delete_user(username, host);
-	if ok then
-		return true, "User deleted";
-	else
-		return nil, "Could not delete user: "..err;
-	end
-end
-
-function def_env.user:password(jid, password)
-	local username, host = jid_split(jid);
-	if not prosody.hosts[host] then
-		return nil, "No such host: "..host;
-	elseif not um.user_exists(username, host) then
-		return nil, "No such user";
-	end
-	local ok, err = um.set_password(username, password, host, nil);
-	if ok then
-		return true, "User password changed";
-	else
-		return nil, "Could not change password for user: "..err;
-	end
-end
-
-function def_env.user:list(host, pat)
-	if not host then
-		return nil, "No host given";
-	elseif not prosody.hosts[host] then
-		return nil, "No such host";
-	end
-	local print = self.session.print;
-	local total, matches = 0, 0;
-	for user in um.users(host) do
-		if not pat or user:match(pat) then
-			print(user.."@"..host);
-			matches = matches + 1;
-		end
-		total = total + 1;
-	end
-	return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users";
-end
-
-def_env.xmpp = {};
-
-local st = require "util.stanza";
-function def_env.xmpp:ping(localhost, remotehost)
-	if prosody.hosts[localhost] then
-		module:send(st.iq{ from=localhost, to=remotehost, type="get", id="ping" }
-				:tag("ping", {xmlns="urn:xmpp:ping"}), prosody.hosts[localhost]);
-		return true, "Sent ping";
-	else
-		return nil, "No such host";
-	end
-end
-
-def_env.dns = {};
-local adns = require"net.adns";
-
-local function get_resolver(session)
-	local resolver = session.dns_resolver;
-	if not resolver then
-		resolver = adns.resolver();
-		session.dns_resolver = resolver;
-	end
-	return resolver;
-end
-
-function def_env.dns:lookup(name, typ, class)
-	local resolver = get_resolver(self.session);
-	local ret = "Query sent";
-	local print = self.session.print;
-	local function handler(...)
-		ret = "Got response";
-		print(...);
-	end
-	resolver:lookup(handler, name, typ, class);
-	return true, ret;
-end
-
-function def_env.dns:addnameserver(...)
-	local resolver = get_resolver(self.session);
-	resolver._resolver:addnameserver(...)
-	return true
-end
-
-function def_env.dns:setnameserver(...)
-	local resolver = get_resolver(self.session);
-	resolver._resolver:setnameserver(...)
-	return true
-end
-
-function def_env.dns:purge()
-	local resolver = get_resolver(self.session);
-	resolver._resolver:purge()
-	return true
-end
-
-function def_env.dns:cache()
-	local resolver = get_resolver(self.session);
-	return true, "Cache:\n"..tostring(resolver._resolver.cache)
-end
-
-def_env.http = {};
-
-function def_env.http:list()
-	local print = self.session.print;
-
-	for host in pairs(prosody.hosts) do
-		local http_apps = modulemanager.get_items("http-provider", host);
-		if #http_apps > 0 then
-			local http_host = module:context(host):get_option_string("http_host");
-			print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":"));
-			for _, provider in ipairs(http_apps) do
-				local url = module:context(host):http_url(provider.name, provider.default_path);
-				print("", url);
-			end
-			print("");
-		end
-	end
-
-	local default_host = module:get_option_string("http_default_host");
-	if not default_host then
-		print("HTTP requests to unknown hosts will return 404 Not Found");
-	else
-		print("HTTP requests to unknown hosts will be handled by "..default_host);
-	end
-	return true;
-end
-
-def_env.debug = {};
-
-function def_env.debug:logevents(host)
-	helpers.log_host_events(host);
-	return true;
-end
-
-function def_env.debug:events(host, event)
-	local events_obj;
-	if host and host ~= "*" then
-		if host == "http" then
-			events_obj = require "net.http.server"._events;
-		elseif not prosody.hosts[host] then
-			return false, "Unknown host: "..host;
-		else
-			events_obj = prosody.hosts[host].events;
-		end
-	else
-		events_obj = prosody.events;
-	end
-	return true, helpers.show_events(events_obj, event);
-end
-
-function def_env.debug:timers()
-	local socket = require "socket";
-	local print = self.session.print;
-	local add_task = require"util.timer".add_task;
-	local h, params = add_task.h, add_task.params;
-	if h then
-		print("-- util.timer");
-		for i, id in ipairs(h.ids) do
-			if not params[id] then
-				print(os.date("%F %T", h.priorities[i]), h.items[id]);
-			elseif not params[id].callback then
-				print(os.date("%F %T", h.priorities[i]), h.items[id], unpack(params[id]));
-			else
-				print(os.date("%F %T", h.priorities[i]), params[id].callback, unpack(params[id]));
-			end
-		end
-	end
-	if server.event_base then
-		local count = 0;
-		for _, v in pairs(debug.getregistry()) do
-			if type(v) == "function" and v.callback and v.callback == add_task._on_timer then
-				count = count + 1;
-			end
-		end
-		print(count .. " libevent callbacks");
-	end
-	if h then
-		local next_time = h:peek();
-		if next_time then
-			return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - socket.gettime());
-		end
-	end
-	return true;
-end
-
--- COMPAT: debug:timers() was timer:info() for some time in trunk
-def_env.timer = { info = def_env.debug.timers };
-
-module:hook("server-stopping", function(event)
-	for _, session in pairs(sessions) do
-		session.print("Shutting down: "..(event.reason or "unknown reason"));
-	end
-end);
-
-def_env.stats = {};
-
-local function format_stat(type, value, ref_value)
-	ref_value = ref_value or value;
-	--do return tostring(value) end
-	if type == "duration" then
-		if ref_value < 0.001 then
-			return ("%d µs"):format(value*1000000);
-		elseif ref_value < 0.9 then
-			return ("%0.2f ms"):format(value*1000);
-		end
-		return ("%0.2f"):format(value);
-	elseif type == "size" then
-		if ref_value > 1048576 then
-			return ("%d MB"):format(value/1048576);
-		elseif ref_value > 1024 then
-			return ("%d KB"):format(value/1024);
-		end
-		return ("%d bytes"):format(value);
-	elseif type == "rate" then
-		if ref_value < 0.9 then
-			return ("%0.2f/min"):format(value*60);
-		end
-		return ("%0.2f/sec"):format(value);
-	end
-	return tostring(value);
-end
-
-local stats_methods = {};
-function stats_methods:bounds(_lower, _upper)
-	for _, stat_info in ipairs(self) do
-		local data = stat_info[4];
-		if data then
-			local lower = _lower or data.min;
-			local upper = _upper or data.max;
-			local new_data = {
-				min = lower;
-				max = upper;
-				samples = {};
-				sample_count = 0;
-				count = data.count;
-				units = data.units;
-			};
-			local sum = 0;
-			for _, v in ipairs(data.samples) do
-				if v > upper then
-					break;
-				elseif v>=lower then
-					table.insert(new_data.samples, v);
-					sum = sum + v;
-				end
-			end
-			new_data.sample_count = #new_data.samples;
-			stat_info[4] = new_data;
-			stat_info[3] = sum/new_data.sample_count;
-		end
-	end
-	return self;
-end
-
-function stats_methods:trim(lower, upper)
-	upper = upper or (100-lower);
-	local statistics = require "util.statistics";
-	for _, stat_info in ipairs(self) do
-		-- Strip outliers
-		local data = stat_info[4];
-		if data then
-			local new_data = {
-				min = statistics.get_percentile(data, lower);
-				max = statistics.get_percentile(data, upper);
-				samples = {};
-				sample_count = 0;
-				count = data.count;
-				units = data.units;
-			};
-			local sum = 0;
-			for _, v in ipairs(data.samples) do
-				if v > new_data.max then
-					break;
-				elseif v>=new_data.min then
-					table.insert(new_data.samples, v);
-					sum = sum + v;
-				end
-			end
-			new_data.sample_count = #new_data.samples;
-			stat_info[4] = new_data;
-			stat_info[3] = sum/new_data.sample_count;
-		end
-	end
-	return self;
-end
-
-function stats_methods:max(upper)
-	return self:bounds(nil, upper);
-end
-
-function stats_methods:min(lower)
-	return self:bounds(lower, nil);
-end
-
-function stats_methods:summary()
-	local statistics = require "util.statistics";
-	for _, stat_info in ipairs(self) do
-		local type, value, data = stat_info[2], stat_info[3], stat_info[4];
-		if data and data.samples then
-			table.insert(stat_info.output, string.format("Count: %d (%d captured)",
-				data.count,
-				data.sample_count
-			));
-			table.insert(stat_info.output, string.format("Min: %s  Mean: %s  Max: %s",
-				format_stat(type, data.min),
-				format_stat(type, value),
-				format_stat(type, data.max)
-			));
-			table.insert(stat_info.output, string.format("Q1: %s  Median: %s  Q3: %s",
-				format_stat(type, statistics.get_percentile(data, 25)),
-				format_stat(type, statistics.get_percentile(data, 50)),
-				format_stat(type, statistics.get_percentile(data, 75))
-			));
-		end
-	end
-	return self;
-end
-
-function stats_methods:cfgraph()
-	for _, stat_info in ipairs(self) do
-		local name, type, value, data = unpack(stat_info, 1, 4);
-		local function print(s)
-			table.insert(stat_info.output, s);
-		end
-
-		if data and data.sample_count and data.sample_count > 0 then
-			local raw_histogram = require "util.statistics".get_histogram(data);
-
-			local graph_width, graph_height = 50, 10;
-			local eighth_chars = "   ▁▂▃▄▅▆▇█";
-
-			local range = data.max - data.min;
-
-			if range > 0 then
-				local x_scaling = #raw_histogram/graph_width;
-				local histogram = {};
-				for i = 1, graph_width do
-					histogram[i] = math.max(raw_histogram[i*x_scaling-1] or 0, raw_histogram[i*x_scaling] or 0);
-				end
-
-				print("");
-				print(("_"):rep(52)..format_stat(type, data.max));
-				for row = graph_height, 1, -1 do
-					local row_chars = {};
-					local min_eighths, max_eighths = 8, 0;
-					for i = 1, #histogram do
-						local char_eighths = math.ceil(math.max(math.min((graph_height/(data.max/histogram[i]))-(row-1), 1), 0)*8);
-						if char_eighths < min_eighths then
-							min_eighths = char_eighths;
-						end
-						if char_eighths > max_eighths then
-							max_eighths = char_eighths;
-						end
-						if char_eighths == 0 then
-							row_chars[i] = "-";
-						else
-							local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
-							row_chars[i] = char;
-						end
-					end
-					print(table.concat(row_chars).."|-"..format_stat(type, data.max/(graph_height/(row-0.5))));
-				end
-				print(("\\    "):rep(11));
-				local x_labels = {};
-				for i = 1, 11 do
-					local s = ("%-4s"):format((i-1)*10);
-					if #s > 4 then
-						s = s:sub(1, 3).."…";
-					end
-					x_labels[i] = s;
-				end
-				print(" "..table.concat(x_labels, " "));
-				local units = "%";
-				local margin = math.floor((graph_width-#units)/2);
-				print((" "):rep(margin)..units);
-			else
-				print("[range too small to graph]");
-			end
-			print("");
-		end
-	end
-	return self;
-end
-
-function stats_methods:histogram()
-	for _, stat_info in ipairs(self) do
-		local name, type, value, data = unpack(stat_info, 1, 4);
-		local function print(s)
-			table.insert(stat_info.output, s);
-		end
-
-		if not data then
-			print("[no data]");
-			return self;
-		elseif not data.sample_count then
-			print("[not a sampled metric type]");
-			return self;
-		end
-
-		local graph_width, graph_height = 50, 10;
-		local eighth_chars = "   ▁▂▃▄▅▆▇█";
-
-		local range = data.max - data.min;
-
-		if range > 0 then
-			local n_buckets = graph_width;
-
-			local histogram = {};
-			for i = 1, n_buckets do
-				histogram[i] = 0;
-			end
-			local max_bin_samples = 0;
-			for _, d in ipairs(data.samples) do
-				local bucket = math.floor(1+(n_buckets-1)/(range/(d-data.min)));
-				histogram[bucket] = histogram[bucket] + 1;
-				if histogram[bucket] > max_bin_samples then
-					max_bin_samples = histogram[bucket];
-				end
-			end
-
-			print("");
-			print(("_"):rep(52)..max_bin_samples);
-			for row = graph_height, 1, -1 do
-				local row_chars = {};
-				local min_eighths, max_eighths = 8, 0;
-				for i = 1, #histogram do
-					local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/histogram[i]))-(row-1), 1), 0)*8);
-					if char_eighths < min_eighths then
-						min_eighths = char_eighths;
-					end
-					if char_eighths > max_eighths then
-						max_eighths = char_eighths;
-					end
-					if char_eighths == 0 then
-						row_chars[i] = "-";
-					else
-						local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
-						row_chars[i] = char;
-					end
-				end
-				print(table.concat(row_chars).."|-"..math.ceil((max_bin_samples/graph_height)*(row-0.5)));
-			end
-			print(("\\    "):rep(11));
-			local x_labels = {};
-			for i = 1, 11 do
-				local s = ("%-4s"):format(format_stat(type, data.min+range*i/11, data.min):match("^%S+"));
-				if #s > 4 then
-					s = s:sub(1, 3).."…";
-				end
-				x_labels[i] = s;
-			end
-			print(" "..table.concat(x_labels, " "));
-			local units = format_stat(type, data.min):match("%s+(.+)$") or data.units or "";
-			local margin = math.floor((graph_width-#units)/2);
-			print((" "):rep(margin)..units);
-		else
-			print("[range too small to graph]");
-		end
-		print("");
-	end
-	return self;
-end
-
-local function stats_tostring(stats)
-	local print = stats.session.print;
-	for _, stat_info in ipairs(stats) do
-		if #stat_info.output > 0 then
-			print("\n#"..stat_info[1]);
-			print("");
-			for _, v in ipairs(stat_info.output) do
-				print(v);
-			end
-			print("");
-		else
-			print(("%-50s %s"):format(stat_info[1], format_stat(stat_info[2], stat_info[3])));
-		end
-	end
-	return #stats.." statistics displayed";
-end
-
-local stats_mt = {__index = stats_methods, __tostring = stats_tostring }
-local function new_stats_context(self)
-	return setmetatable({ session = self.session, stats = true }, stats_mt);
-end
-
-function def_env.stats:show(filter)
-	local stats, changed, extra = require "core.statsmanager".get_stats();
-	local available, displayed = 0, 0;
-	local displayed_stats = new_stats_context(self);
-	for name, value in pairs(stats) do
-		available = available + 1;
-		if not filter or name:match(filter) then
-			displayed = displayed + 1;
-			local type = name:match(":(%a+)$");
-			table.insert(displayed_stats, {
-				name, type, value, extra[name];
-				output = {};
-			});
-		end
-	end
-	return displayed_stats;
-end
-
-
-
--------------
-
-function printbanner(session)
-	local option = module:get_option_string("console_banner", "full");
-	if option == "full" or option == "graphic" then
-		session.print [[
-                   ____                \   /     _
-                    |  _ \ _ __ ___  ___  _-_   __| |_   _
-                    | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
-                    |  __/| | | (_) \__ \ |_| | (_| | |_| |
-                    |_|   |_|  \___/|___/\___/ \__,_|\__, |
-                    A study in simplicity            |___/
-
-]]
-	end
-	if option == "short" or option == "full" then
-	session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
-	session.print("You may find more help on using this console in our online documentation at ");
-	session.print("https://prosody.im/doc/console\n");
-	end
-	if option ~= "short" and option ~= "full" and option ~= "graphic" then
-		session.print(option);
-	end
-end
-
 module:provides("net", {
 	name = "console";
 	listener = console_listener;
--- a/plugins/mod_announce.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_announce.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -38,6 +38,7 @@
 -- Old <message>-based jabberd-style announcement sending
 function handle_announcement(event)
 	local stanza = event.stanza;
+	-- luacheck: ignore 211/node
 	local node, host, resource = jid.split(stanza.attr.to);
 
 	if resource ~= "announce/online" then
--- a/plugins/mod_auth_anonymous.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_auth_anonymous.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -11,6 +11,8 @@
 local datamanager = require "util.datamanager";
 local hosts = prosody.hosts;
 
+local allow_storage = module:get_option_boolean("allow_anonymous_storage", false);
+
 -- define auth provider
 local provider = {};
 
@@ -62,10 +64,14 @@
 end
 
 function module.load()
-	datamanager.add_callback(dm_callback);
+	if not allow_storage then
+		datamanager.add_callback(dm_callback);
+	end
 end
 function module.unload()
-	datamanager.remove_callback(dm_callback);
+	if not allow_storage then
+		datamanager.remove_callback(dm_callback);
+	end
 end
 
 module:provides("auth", provider);
--- a/plugins/mod_auth_cyrus.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,85 +0,0 @@
--- Prosody IM
--- 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.
---
--- luacheck: ignore 212
-
-local log = require "util.logger".init("auth_cyrus");
-
-local usermanager_user_exists = require "core.usermanager".user_exists;
-
-local cyrus_service_realm = module:get_option("cyrus_service_realm");
-local cyrus_service_name = module:get_option("cyrus_service_name");
-local cyrus_application_name = module:get_option("cyrus_application_name");
-local require_provisioning = module:get_option("cyrus_require_provisioning") or false;
-local host_fqdn = module:get_option("cyrus_server_fqdn");
-
-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;
-prosody.lock_globals();
-local new_sasl = function(realm)
-	return cyrus_new(
-		cyrus_service_realm or realm,
-		cyrus_service_name or "xmpp",
-		cyrus_application_name or "prosody",
-		host_fqdn
-	);
-end
-
-do -- diagnostic
-	local list;
-	for mechanism in pairs(new_sasl(module.host):mechanisms()) do
-		list = (not(list) and mechanism) or (list..", "..mechanism);
-	end
-	if not list then
-		module:log("error", "No Cyrus SASL mechanisms available");
-	else
-		module:log("debug", "Available Cyrus SASL mechanisms: %s", list);
-	end
-end
-
-local host = module.host;
-
--- define auth provider
-local provider = {};
-log("debug", "initializing default authentication provider for host '%s'", host);
-
-function provider.test_password(username, password)
-	return nil, "Legacy auth not supported with Cyrus SASL.";
-end
-
-function provider.get_password(username)
-	return nil, "Passwords unavailable for Cyrus SASL.";
-end
-
-function provider.set_password(username, password)
-	return nil, "Passwords unavailable for Cyrus SASL.";
-end
-
-function provider.user_exists(username)
-	if require_provisioning then
-		return usermanager_user_exists(username, host);
-	end
-	return true;
-end
-
-function provider.create_user(username, password)
-	return nil, "Account creation/modification not available with Cyrus SASL.";
-end
-
-function provider.get_sasl_handler()
-	local handler = new_sasl(host);
-	if require_provisioning then
-		function handler.require_provisioning(username)
-			return usermanager_user_exists(username, host);
-		end
-	end
-	return handler;
-end
-
-module:provides("auth", provider);
-
--- a/plugins/mod_auth_internal_hashed.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_auth_internal_hashed.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,12 +9,12 @@
 
 local max = math.max;
 
-local getAuthenticationDatabaseSHA1 = require "util.sasl.scram".getAuthenticationDatabaseSHA1;
+local scram_hashers = require "util.sasl.scram".hashers;
 local usermanager = require "core.usermanager";
 local generate_uuid = require "util.uuid".generate;
 local new_sasl = require "util.sasl".new;
 local hex = require"util.hex";
-local to_hex, from_hex = hex.to, hex.from;
+local to_hex, from_hex = hex.encode, hex.decode;
 local saslprep = require "util.encodings".stringprep.saslprep;
 local secure_equals = require "util.hashes".equals;
 
@@ -23,10 +23,12 @@
 
 local accounts = module:open_store("accounts");
 
-
+local hash_name = module:get_option_string("password_hash", "SHA-1");
+local get_auth_db = assert(scram_hashers[hash_name], "SCRAM-"..hash_name.." not supported by SASL library");
+local scram_name = "scram_"..hash_name:gsub("%-","_"):lower();
 
 -- Default; can be set per-user
-local default_iteration_count = 4096;
+local default_iteration_count = module:get_option_number("default_iteration_count", 10000);
 
 -- define auth provider
 local provider = {};
@@ -55,7 +57,7 @@
 		return nil, "Auth failed. Stored salt and iteration count information is not complete.";
 	end
 
-	local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, credentials.salt, credentials.iteration_count);
+	local valid, stored_key, server_key = get_auth_db(password, credentials.salt, credentials.iteration_count);
 
 	local stored_key_hex = to_hex(stored_key);
 	local server_key_hex = to_hex(server_key);
@@ -73,7 +75,7 @@
 	if account then
 		account.salt = generate_uuid();
 		account.iteration_count = max(account.iteration_count or 0, default_iteration_count);
-		local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, account.salt, account.iteration_count);
+		local valid, stored_key, server_key = get_auth_db(password, account.salt, account.iteration_count);
 		if not valid then
 			return valid, stored_key;
 		end
@@ -107,7 +109,7 @@
 		return accounts:set(username, {});
 	end
 	local salt = generate_uuid();
-	local valid, stored_key, server_key = getAuthenticationDatabaseSHA1(password, salt, default_iteration_count);
+	local valid, stored_key, server_key = get_auth_db(password, salt, default_iteration_count);
 	if not valid then
 		return valid, stored_key;
 	end
@@ -128,7 +130,7 @@
 		plain_test = function(_, username, password, realm)
 			return usermanager.test_password(username, realm, password), true;
 		end,
-		scram_sha_1 = function(_, username)
+		[scram_name] = function(_, username)
 			local credentials = accounts:get(username);
 			if not credentials then return; end
 			if credentials.password then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_auth_ldap.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,154 @@
+-- mod_auth_ldap
+
+local jid_split = require "util.jid".split;
+local new_sasl = require "util.sasl".new;
+local lualdap = require "lualdap";
+
+local function ldap_filter_escape(s)
+	return (s:gsub("[*()\\%z]", function(c) return ("\\%02x"):format(c:byte()) end));
+end
+
+-- Config options
+local ldap_server = module:get_option_string("ldap_server", "localhost");
+local ldap_rootdn = module:get_option_string("ldap_rootdn", "");
+local ldap_password = module:get_option_string("ldap_password", "");
+local ldap_tls = module:get_option_boolean("ldap_tls");
+local ldap_scope = module:get_option_string("ldap_scope", "subtree");
+local ldap_filter = module:get_option_string("ldap_filter", "(uid=$user)"):gsub("%%s", "$user", 1);
+local ldap_base = assert(module:get_option_string("ldap_base"), "ldap_base is a required option for ldap");
+local ldap_mode = module:get_option_string("ldap_mode", "bind");
+local ldap_admins = module:get_option_string("ldap_admin_filter",
+	module:get_option_string("ldap_admins")); -- COMPAT with mistake in documentation
+local host = ldap_filter_escape(module:get_option_string("realm", module.host));
+
+-- Initiate connection
+local ld = nil;
+module.unload = function() if ld then pcall(ld, ld.close); end end
+
+function ldap_do_once(method, ...)
+	if ld == nil then
+		local err;
+		ld, err = lualdap.open_simple(ldap_server, ldap_rootdn, ldap_password, ldap_tls);
+		if not ld then return nil, err, "reconnect"; end
+	end
+
+	-- luacheck: ignore 411/success
+	local success, iterator, invariant, initial = pcall(ld[method], ld, ...);
+	if not success then ld = nil; return nil, iterator, "search"; end
+
+	local success, dn, attr = pcall(iterator, invariant, initial);
+	if not success then ld = nil; return success, dn, "iter"; end
+
+	return dn, attr, "return";
+end
+
+function ldap_do(method, retry_count, ...)
+	local dn, attr, where;
+	for _=1,1+retry_count do
+		dn, attr, where = ldap_do_once(method, ...);
+		if dn or not(attr) then break; end -- nothing or something found
+		module:log("warn", "LDAP: %s %s (in %s)", tostring(dn), tostring(attr), where);
+		-- otherwise retry
+	end
+	if not dn and attr then
+		module:log("error", "LDAP: %s", tostring(attr));
+	end
+	return dn, attr;
+end
+
+function get_user(username)
+	module:log("debug", "get_user(%q)", username);
+	return ldap_do("search", 2, {
+		base = ldap_base;
+		scope = ldap_scope;
+		sizelimit = 1;
+		filter = ldap_filter:gsub("%$(%a+)", {
+			user = ldap_filter_escape(username);
+			host = host;
+		});
+	});
+end
+
+local provider = {};
+
+function provider.create_user(username, password) -- luacheck: ignore 212
+	return nil, "Account creation not available with LDAP.";
+end
+
+function provider.user_exists(username)
+	return not not get_user(username);
+end
+
+function provider.set_password(username, password)
+	local dn, attr = get_user(username);
+	if not dn then return nil, attr end
+	if attr.userPassword == password then return true end
+	return ldap_do("modify", 2, dn, { '=', userPassword = password });
+end
+
+if ldap_mode == "getpasswd" then
+	function provider.get_password(username)
+		local dn, attr = get_user(username);
+		if dn and attr then
+			return attr.userPassword;
+		end
+	end
+
+	function provider.test_password(username, password)
+		return provider.get_password(username) == password;
+	end
+
+	function provider.get_sasl_handler()
+		return new_sasl(module.host, {
+			plain = function(sasl, username) -- luacheck: ignore 212/sasl
+				local password = provider.get_password(username);
+				if not password then return "", nil; end
+				return password, true;
+			end
+		});
+	end
+elseif ldap_mode == "bind" then
+	local function test_password(userdn, password)
+		local ok, err = lualdap.open_simple(ldap_server, userdn, password, ldap_tls);
+		if not ok then
+			module:log("debug", "ldap open_simple error: %s", err);
+		end
+		return not not ok;
+	end
+
+	function provider.test_password(username, password)
+		local dn = get_user(username);
+		if not dn then return end
+		return test_password(dn, password)
+	end
+
+	function provider.get_sasl_handler()
+		return new_sasl(module.host, {
+			plain_test = function(sasl, username, password) -- luacheck: ignore 212/sasl
+				return provider.test_password(username, password), true;
+			end
+		});
+	end
+else
+	module:log("error", "Unsupported ldap_mode %s", tostring(ldap_mode));
+end
+
+if ldap_admins then
+	function provider.is_admin(jid)
+		local username, user_host = jid_split(jid);
+		if user_host ~= module.host then
+			return false;
+		end
+		return ldap_do("search", 2, {
+			base = ldap_base;
+			scope = ldap_scope;
+			sizelimit = 1;
+			filter = ldap_admins:gsub("%$(%a+)", {
+				user = ldap_filter_escape(username);
+				host = host;
+			});
+		});
+	end
+end
+
+module:provides("auth", provider);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_authz_internal.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,59 @@
+local array = require "util.array";
+local it = require "util.iterators";
+local set = require "util.set";
+local jid_split = require "util.jid".split;
+local normalize = require "util.jid".prep;
+local config_admin_jids = module:get_option_inherited_set("admins", {}) / normalize;
+local host = module.host;
+local role_store = module:open_store("roles");
+local role_map_store = module:open_store("roles", "map");
+
+local admin_role = { ["prosody:admin"] = true };
+
+function get_user_roles(user)
+	if config_admin_jids:contains(user.."@"..host) then
+		return admin_role;
+	end
+	return role_store:get(user);
+end
+
+function set_user_roles(user, roles)
+	role_store:set(user, roles)
+	return true;
+end
+
+function get_users_with_role(role)
+	local storage_role_users = it.to_array(it.keys(role_map_store:get_all(role) or {}));
+	if role == "prosody:admin" then
+		local config_admin_users = config_admin_jids / function (admin_jid)
+			local j_node, j_host = jid_split(admin_jid);
+			if j_host == host then
+				return j_node;
+			end
+		end;
+		return it.to_array(config_admin_users + set.new(storage_role_users));
+	end
+	return storage_role_users;
+end
+
+function get_jid_roles(jid)
+	if config_admin_jids:contains(jid) then
+		return admin_role;
+	end
+	return nil;
+end
+
+function set_jid_roles(jid) -- luacheck: ignore 212
+	return false;
+end
+
+function get_jids_with_role(role)
+	-- Fetch role users from storage
+	local storage_role_jids = array.map(get_users_with_role(role), function (username)
+		return username.."@"..host;
+	end);
+	if role == "prosody:admin" then
+		return it.to_array(config_admin_jids + set.new(storage_role_jids));
+	end
+	return storage_role_jids;
+end
--- a/plugins/mod_blocklist.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_blocklist.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -67,7 +67,7 @@
 		if item.type == "jid" and item.action == "deny" then
 			local jid = jid_prep(item.value);
 			if not jid then
-				module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, tostring(item.value));
+				module:log("warn", "Invalid JID in privacy store for user '%s' not migrated: %s", username, item.value);
 			else
 				migrated_data[jid] = true;
 			end
@@ -162,7 +162,7 @@
 	local blocklist = cache[username] or get_blocklist(username);
 
 	local new_blocklist = {
-		-- We set the [false] key to someting as a signal not to migrate privacy lists
+		-- We set the [false] key to something as a signal not to migrate privacy lists
 		[false] = blocklist[false] or { created = now; };
 	};
 	if type(blocklist[false]) == "table" then
@@ -189,6 +189,7 @@
 
 	if is_blocking then
 		for jid in pairs(send_unavailable) do
+			-- Check that this JID isn't already blocked, i.e. this is not a change
 			if not blocklist[jid] then
 				for _, session in pairs(sessions[username].sessions) do
 					if session.presence then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_bookmarks.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,485 @@
+local mm = require "core.modulemanager";
+if mm.get_modules_for_host(module.host):contains("bookmarks2") then
+	error("mod_bookmarks and mod_bookmarks2 are conflicting, please disable one of them.", 0);
+end
+
+local st = require "util.stanza";
+local jid_split = require "util.jid".split;
+
+local mod_pep = module:depends "pep";
+local private_storage = module:open_store("private", "map");
+
+local namespace = "urn:xmpp:bookmarks:1";
+local namespace_old = "urn:xmpp:bookmarks:0";
+local namespace_private = "jabber:iq:private";
+local namespace_legacy = "storage:bookmarks";
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+
+local default_options = {
+	["persist_items"] = true;
+	["max_items"] = "max";
+	["send_last_published_item"] = "never";
+	["access_model"] = "whitelist";
+};
+
+module:hook("account-disco-info", function (event)
+	-- This Time it’s Serious!
+	event.reply:tag("feature", { var = namespace.."#compat" }):up();
+	event.reply:tag("feature", { var = namespace.."#compat-pep" }):up();
+
+	-- COMPAT XEP-0411
+	event.reply:tag("feature", { var = "urn:xmpp:bookmarks-conversion:0" }):up();
+end);
+
+-- This must be declared on the domain JID, not the account JID.  Note that
+-- this isn’t defined in the XEP.
+module:add_feature(namespace_private);
+
+
+local function generate_legacy_storage(items)
+	local storage = st.stanza("storage", { xmlns = namespace_legacy });
+	for _, item_id in ipairs(items) do
+		local item = items[item_id];
+		local bookmark = item:get_child("conference", namespace);
+		if not bookmark then
+			module:log("warn", "Invalid bookmark published: expected {%s}conference, got {%s}%s", namespace,
+
+				item.tags[1] and item.tags[1].attr.xmlns, item.tags[1] and item.tags[1].name);
+		end
+		local conference = st.stanza("conference", {
+			jid = item.attr.id,
+			name = bookmark and bookmark.attr.name,
+			autojoin = bookmark and bookmark.attr.autojoin,
+		});
+		local nick = bookmark and bookmark:get_child_text("nick");
+		if nick ~= nil then
+			conference:text_tag("nick", nick):up();
+		end
+		local password = bookmark and bookmark:get_child_text("password");
+		if password ~= nil then
+			conference:text_tag("password", password):up();
+		end
+		storage:add_child(conference);
+	end
+
+	return storage;
+end
+
+local function on_retrieve_legacy_pep(event)
+	local stanza, session = event.stanza, event.origin;
+	local pubsub = stanza:get_child("pubsub", "http://jabber.org/protocol/pubsub");
+	if pubsub == nil then
+		return;
+	end
+
+	local items = pubsub:get_child("items");
+	if items == nil then
+		return;
+	end
+
+	local node = items.attr.node;
+	if node ~= namespace_legacy then
+		return;
+	end
+
+	local username = session.username;
+	local jid = username.."@"..session.host;
+	local service = mod_pep.get_pep_service(username);
+	local ok, ret = service:get_items(namespace, session.full_jid);
+	if not ok then
+		if ret == "item-not-found" then
+			module:log("debug", "Got no PEP bookmarks item for %s, returning empty private bookmarks", jid);
+		else
+			module:log("error", "Failed to retrieve PEP bookmarks of %s: %s", jid, ret);
+		end
+		session.send(st.error_reply(stanza, "cancel", ret, "Failed to retrieve bookmarks from PEP"));
+		return true;
+	end
+
+	local storage = generate_legacy_storage(ret);
+
+	module:log("debug", "Sending back legacy PEP for %s: %s", jid, storage);
+	session.send(st.reply(stanza)
+		:tag("pubsub", {xmlns = "http://jabber.org/protocol/pubsub"})
+			:tag("items", {node = namespace_legacy})
+				:tag("item", {id = "current"})
+					:add_child(storage));
+	return true;
+end
+
+local function on_retrieve_private_xml(event)
+	local stanza, session = event.stanza, event.origin;
+	local query = stanza:get_child("query", namespace_private);
+	if query == nil then
+		return;
+	end
+
+	local bookmarks = query:get_child("storage", namespace_legacy);
+	if bookmarks == nil then
+		return;
+	end
+
+	module:log("debug", "Getting private bookmarks: %s", bookmarks);
+
+	local username = session.username;
+	local jid = username.."@"..session.host;
+	local service = mod_pep.get_pep_service(username);
+	local ok, ret = service:get_items(namespace, session.full_jid);
+	if not ok then
+		if ret == "item-not-found" then
+			module:log("debug", "Got no PEP bookmarks item for %s, returning empty private bookmarks", jid);
+			session.send(st.reply(stanza):add_child(query));
+		else
+			module:log("error", "Failed to retrieve PEP bookmarks of %s: %s", jid, ret);
+			session.send(st.error_reply(stanza, "cancel", ret, "Failed to retrieve bookmarks from PEP"));
+		end
+		return true;
+	end
+
+	local storage = generate_legacy_storage(ret);
+
+	module:log("debug", "Sending back private for %s: %s", jid, storage);
+	session.send(st.reply(stanza):query(namespace_private):add_child(storage));
+	return true;
+end
+
+local function compare_bookmark2(a, b)
+	if a == nil or b == nil then
+		return false;
+	end
+	local a_conference = a:get_child("conference", namespace);
+	local b_conference = b:get_child("conference", namespace);
+	local a_nick = a_conference:get_child_text("nick");
+	local b_nick = b_conference:get_child_text("nick");
+	local a_password = a_conference:get_child_text("password");
+	local b_password = b_conference:get_child_text("password");
+	return (a.attr.id == b.attr.id and
+	        a_conference.attr.name == b_conference.attr.name and
+	        a_conference.attr.autojoin == b_conference.attr.autojoin and
+	        a_nick == b_nick and
+	        a_password == b_password);
+end
+
+local function publish_to_pep(jid, bookmarks, synchronise)
+	local service = mod_pep.get_pep_service(jid_split(jid));
+
+	if #bookmarks.tags == 0 then
+		if synchronise then
+			-- If we set zero legacy bookmarks, purge the bookmarks 2 node.
+			module:log("debug", "No bookmark in the set, purging instead.");
+			return service:purge(namespace, jid, true);
+		else
+			return true;
+		end
+	end
+
+	-- Retrieve the current bookmarks2.
+	module:log("debug", "Retrieving the current bookmarks 2.");
+	local has_bookmarks2, ret = service:get_items(namespace, jid);
+	local bookmarks2;
+	if not has_bookmarks2 and ret == "item-not-found" then
+		module:log("debug", "Got item-not-found, assuming it was empty until now, creating.");
+		local ok, err = service:create(namespace, jid, default_options);
+		if not ok then
+			module:log("error", "Creating bookmarks 2 node failed: %s", err);
+			return ok, err;
+		end
+		bookmarks2 = {};
+	elseif not has_bookmarks2 then
+		module:log("debug", "Got %s error, aborting.", ret);
+		return false, ret;
+	else
+		module:log("debug", "Got existing bookmarks2.");
+		bookmarks2 = ret;
+
+		local ok, err = service:get_node_config(namespace, jid);
+		if not ok then
+			module:log("error", "Retrieving bookmarks 2 node config failed: %s", err);
+			return ok, err;
+		end
+
+		local options = err;
+		for key, value in pairs(default_options) do
+			if options[key] and options[key] ~= value then
+				module:log("warn", "Overriding bookmarks 2 configuration for %s, from %s to %s", jid, options[key], value);
+				options[key] = value;
+			end
+		end
+
+		local ok, err = service:set_node_config(namespace, jid, options);
+		if not ok then
+			module:log("error", "Setting bookmarks 2 node config failed: %s", err);
+			return ok, err;
+		end
+	end
+
+	-- Get a list of all items we may want to remove.
+	local to_remove = {};
+	for i in ipairs(bookmarks2) do
+		to_remove[bookmarks2[i]] = true;
+	end
+
+	for bookmark in bookmarks:childtags("conference", namespace_legacy) do
+		-- Create the new conference element by copying everything from the legacy one.
+		local conference = st.stanza("conference", {
+			xmlns = namespace,
+			name = bookmark.attr.name,
+			autojoin = bookmark.attr.autojoin,
+		});
+		local nick = bookmark:get_child_text("nick");
+		if nick ~= nil then
+			conference:text_tag("nick", nick):up();
+		end
+		local password = bookmark:get_child_text("password");
+		if password ~= nil then
+			conference:text_tag("password", password):up();
+		end
+
+		-- Create its wrapper.
+		local item = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = bookmark.attr.jid })
+			:add_child(conference);
+
+		-- Then publish it only if it’s a new one or updating a previous one.
+		if compare_bookmark2(item, bookmarks2[bookmark.attr.jid]) then
+			module:log("debug", "Item %s identical to the previous one, skipping.", item.attr.id);
+			to_remove[bookmark.attr.jid] = nil;
+		else
+			if bookmarks2[bookmark.attr.jid] == nil then
+				module:log("debug", "Item %s not existing previously, publishing.", item.attr.id);
+			else
+				module:log("debug", "Item %s different from the previous one, publishing.", item.attr.id);
+				to_remove[bookmark.attr.jid] = nil;
+			end
+			local ok, err = service:publish(namespace, jid, bookmark.attr.jid, item, default_options);
+			if not ok then
+				module:log("error", "Publishing item %s failed: %s", item.attr.id, err);
+				return ok, err;
+			end
+		end
+	end
+
+	-- Now handle retracting items that have been removed.
+	if synchronise then
+		for id in pairs(to_remove) do
+			module:log("debug", "Item %s removed from bookmarks.", id);
+			local ok, err = service:retract(namespace, jid, id, st.stanza("retract", { id = id }));
+			if not ok then
+				module:log("error", "Retracting item %s failed: %s", id, err);
+				return ok, err;
+			end
+		end
+	end
+	return true;
+end
+
+-- Synchronise legacy PEP to PEP.
+local function on_publish_legacy_pep(event)
+	local stanza, session = event.stanza, event.origin;
+	local pubsub = stanza:get_child("pubsub", "http://jabber.org/protocol/pubsub");
+	if pubsub == nil then
+		return;
+	end
+
+	local publish = pubsub:get_child("publish");
+	if publish == nil then return end
+	if publish.attr.node == namespace_old then
+		session.send(st.error_reply(stanza, "modify", "not-allowed",
+			"Your client does XEP-0402 version 0.3.0 but 0.4.0+ is required"));
+		return true;
+	end
+	if publish.attr.node ~= namespace_legacy then
+		return;
+	end
+
+	local item = publish:get_child("item");
+	if item == nil then
+		return;
+	end
+
+	-- Here we ignore the item id, it’ll be generated as 'current' anyway.
+
+	local bookmarks = item:get_child("storage", namespace_legacy);
+	if bookmarks == nil then
+		return;
+	end
+
+	-- We also ignore the publish-options.
+
+	module:log("debug", "Legacy PEP bookmarks set by client, publishing to PEP.");
+
+	local ok, err = publish_to_pep(session.full_jid, bookmarks, true);
+	if not ok then
+		module:log("error", "Failed to publish to PEP bookmarks for %s@%s: %s", session.username, session.host, err);
+		session.send(st.error_reply(stanza, "cancel", "internal-server-error", "Failed to store bookmarks to PEP"));
+		return true;
+	end
+
+	session.send(st.reply(stanza));
+	return true;
+end
+
+-- Synchronise Private XML to PEP.
+local function on_publish_private_xml(event)
+	local stanza, session = event.stanza, event.origin;
+	local query = stanza:get_child("query", namespace_private);
+	if query == nil then
+		return;
+	end
+
+	local bookmarks = query:get_child("storage", namespace_legacy);
+	if bookmarks == nil then
+		return;
+	end
+
+	module:log("debug", "Private bookmarks set by client, publishing to PEP.");
+
+	local ok, err = publish_to_pep(session.full_jid, bookmarks, true);
+	if not ok then
+		module:log("error", "Failed to publish to PEP bookmarks for %s@%s: %s", session.username, session.host, err);
+		session.send(st.error_reply(stanza, "cancel", "internal-server-error", "Failed to store bookmarks to PEP"));
+		return true;
+	end
+
+	session.send(st.reply(stanza));
+	return true;
+end
+
+local function migrate_legacy_bookmarks(event)
+	local session = event.session;
+	local username = session.username;
+	local service = mod_pep.get_pep_service(username);
+	local jid = username.."@"..session.host;
+
+	local ok, ret = service:get_items(namespace_legacy, session.full_jid);
+	if ok and ret[1] then
+		module:log("debug", "Legacy PEP bookmarks found for %s, migrating.", jid);
+		local failed = false;
+		for _, item_id in ipairs(ret) do
+			local item = ret[item_id];
+			if item.attr.id ~= "current" then
+				module:log("warn", "Legacy PEP bookmarks for %s isn’t using 'current' as its id: %s", jid, item.attr.id);
+			end
+			local bookmarks = item:get_child("storage", namespace_legacy);
+			module:log("debug", "Got legacy PEP bookmarks of %s: %s", jid, bookmarks);
+
+			local ok, err = publish_to_pep(session.full_jid, bookmarks, false);
+			if not ok then
+				module:log("error", "Failed to store legacy PEP bookmarks to bookmarks 2 for %s, aborting migration: %s", jid, err);
+				failed = true;
+				break;
+			end
+		end
+		if not failed then
+			module:log("debug", "Successfully migrated legacy PEP bookmarks of %s to bookmarks 2, clearing items.", jid);
+			local ok, err = service:purge(namespace_legacy, jid, false);
+			if not ok then
+				module:log("error", "Failed to delete legacy PEP bookmarks for %s: %s", jid, err);
+			end
+		end
+	end
+
+	local ok, current_legacy_config = service:get_node_config(namespace_legacy, jid);
+	if not ok or current_legacy_config["access_model"] ~= "whitelist" then
+		-- The legacy node must exist in order for the access model to apply to the
+		-- XEP-0411 COMPAT broadcasts (which bypass the pubsub service entirely),
+		-- so create or reconfigure it to be useless.
+		--
+		-- FIXME It would be handy to have a publish model that prevents the owner
+		-- from publishing, but the affiliation takes priority
+		local config = {
+			["persist_items"] = false;
+			["max_items"] = 1;
+			["send_last_published_item"] = "never";
+			["access_model"] = "whitelist";
+		};
+		local ok, err;
+		if ret == "item-not-found" then
+			ok, err = service:create(namespace_legacy, jid, config);
+		else
+			ok, err = service:set_node_config(namespace_legacy, jid, config);
+		end
+		if not ok then
+			module:log("error", "Setting legacy bookmarks node config failed: %s", err);
+			return ok, err;
+		end
+	end
+
+	local data, err = private_storage:get(username, "storage:storage:bookmarks");
+	if not data then
+		module:log("debug", "No existing legacy bookmarks for %s, migration already done: %s", jid, err);
+		local ok, ret2 = service:get_items(namespace, session.full_jid);
+		if not ok or not ret2 then
+			module:log("debug", "Additionally, no bookmarks 2 were existing for %s, assuming empty.", jid);
+			module:fire_event("bookmarks/empty", { session = session });
+		end
+		return;
+	end
+	local bookmarks = st.deserialize(data);
+	module:log("debug", "Got legacy bookmarks of %s: %s", jid, bookmarks);
+
+	module:log("debug", "Going to store legacy bookmarks to bookmarks 2 %s.", jid);
+	local ok, err = publish_to_pep(session.full_jid, bookmarks, false);
+	if not ok then
+		module:log("error", "Failed to store legacy bookmarks to bookmarks 2 for %s, aborting migration: %s", jid, err);
+		return;
+	end
+	module:log("debug", "Stored legacy bookmarks to bookmarks 2 for %s.", jid);
+
+	local ok, err = private_storage:set(username, "storage:storage:bookmarks", nil);
+	if not ok then
+		module:log("error", "Failed to remove legacy bookmarks of %s: %s", jid, err);
+		return;
+	end
+	module:log("debug", "Removed legacy bookmarks of %s, migration done!", jid);
+end
+
+module:hook("iq/bare/jabber:iq:private:query", function (event)
+	if event.stanza.attr.type == "get" then
+		return on_retrieve_private_xml(event);
+	else
+		return on_publish_private_xml(event);
+	end
+end, 1);
+module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function (event)
+	if event.stanza.attr.type == "get" then
+		return on_retrieve_legacy_pep(event);
+	else
+		return on_publish_legacy_pep(event);
+	end
+end, 1);
+if module:get_option_boolean("upgrade_legacy_bookmarks", true) then
+	module:hook("resource-bind", migrate_legacy_bookmarks);
+end
+-- COMPAT XEP-0411 Broadcast as per XEP-0048 + PEP
+local function legacy_broadcast(event)
+	local service = event.service;
+	local ok, bookmarks = service:get_items(namespace, event.actor);
+	if bookmarks == "item-not-found" then ok, bookmarks = true, {} end
+	if not ok then return end
+	local legacy_bookmarks_item = st.stanza("item", { xmlns = xmlns_pubsub; id = "current" })
+		:add_child(generate_legacy_storage(bookmarks));
+	service:broadcast("items", namespace_legacy, { --[[ no subscribers ]] }, legacy_bookmarks_item, event.actor);
+end
+local function broadcast_legacy_removal(event)
+	if event.node ~= namespace then return end
+	return legacy_broadcast(event);
+end
+module:hook("presence/initial", function (event)
+	-- Broadcasts to all clients with legacy+notify, not just the one coming online.
+	-- Upgrade to XEP-0402 to avoid it
+	local service = mod_pep.get_pep_service(event.origin.username);
+	legacy_broadcast({ service = service, actor = event.origin.full_jid });
+end);
+module:handle_items("pep-service", function (event)
+	local service = event.item.service;
+	module:hook_object_event(service.events, "item-published/" .. namespace, legacy_broadcast);
+	module:hook_object_event(service.events, "item-retracted", broadcast_legacy_removal);
+	module:hook_object_event(service.events, "node-purged", broadcast_legacy_removal);
+	module:hook_object_event(service.events, "node-deleted", broadcast_legacy_removal);
+end, function (event)
+	local service = event.item.service;
+	module:unhook_object_event(service.events, "item-published/" .. namespace, legacy_broadcast);
+	module:unhook_object_event(service.events, "item-retracted", broadcast_legacy_removal);
+	module:unhook_object_event(service.events, "node-purged", broadcast_legacy_removal);
+	module:unhook_object_event(service.events, "node-deleted", broadcast_legacy_removal);
+end, true);
--- a/plugins/mod_bosh.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_bosh.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -44,20 +44,42 @@
 local bosh_max_wait = module:get_option_number("bosh_max_wait", 120);
 
 local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure");
-local cross_domain = module:get_option("cross_domain_bosh", false);
+local cross_domain = module:get_option("cross_domain_bosh");
 local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256);
 
-if cross_domain == true then cross_domain = "*"; end
-if type(cross_domain) == "table" then cross_domain = table.concat(cross_domain, ", "); end
+if cross_domain ~= nil then
+	module:log("info", "The 'cross_domain_bosh' option has been deprecated");
+end
 
 local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
 
 -- All sessions, and sessions that have no requests open
 local sessions = module:shared("sessions");
 
+local measure_active = module:measure("active_sessions", "amount");
+local measure_inactive = module:measure("inactive_sessions", "amount");
+local report_bad_host = module:measure("bad_host", "rate");
+local report_bad_sid = module:measure("bad_sid", "rate");
+local report_new_sid = module:measure("new_sid", "rate");
+local report_timeout = module:measure("timeout", "rate");
+
+module:hook("stats-update", function ()
+	local active = 0;
+	local inactive = 0;
+	for _, session in pairs(sessions) do
+		if #session.requests > 0 then
+			active = active + 1;
+		else
+			inactive = inactive + 1;
+		end
+	end
+	measure_active(active);
+	measure_inactive(inactive);
+end);
+
 -- Used to respond to idle sessions (those with waiting requests)
 function on_destroy_request(request)
-	log("debug", "Request destroyed: %s", tostring(request));
+	log("debug", "Request destroyed: %s", request);
 	local session = sessions[request.context.sid];
 	if session then
 		local requests = session.requests;
@@ -74,7 +96,7 @@
 			if session.inactive_timer then
 				session.inactive_timer:stop();
 			end
-			session.inactive_timer = module:add_timer(max_inactive, check_inactive, session, request.context,
+			session.inactive_timer = module:add_timer(max_inactive, session_timeout, session, request.context,
 				"BOSH client silent for over "..max_inactive.." seconds");
 			(session.log or log)("debug", "BOSH session marked as inactive (for %ds)", max_inactive);
 		end
@@ -85,31 +107,16 @@
 	end
 end
 
-function check_inactive(now, session, context, reason) -- luacheck: ignore 212/now
+function session_timeout(now, session, context, reason) -- luacheck: ignore 212/now
 	if not session.destroyed then
+		report_timeout();
 		sessions[context.sid] = nil;
 		sm_destroy_session(session, reason);
 	end
 end
 
-local function set_cross_domain_headers(response)
-	local headers = response.headers;
-	headers.access_control_allow_methods = "GET, POST, OPTIONS";
-	headers.access_control_allow_headers = "Content-Type";
-	headers.access_control_max_age = "7200";
-	headers.access_control_allow_origin = cross_domain;
-	return response;
-end
-
-function handle_OPTIONS(event)
-	if cross_domain and event.request.headers.origin then
-		set_cross_domain_headers(event.response);
-	end
-	return "";
-end
-
 function handle_POST(event)
-	log("debug", "Handling new request %s: %s\n----------", tostring(event.request), tostring(event.request.body));
+	log("debug", "Handling new request %s: %s\n----------", event.request, event.request.body);
 
 	local request, response = event.request, event.response;
 	response.on_destroy = on_destroy_request;
@@ -122,10 +129,6 @@
 	local headers = response.headers;
 	headers.content_type = "text/xml; charset=utf-8";
 
-	if cross_domain and request.headers.origin then
-		set_cross_domain_headers(response);
-	end
-
 	-- stream:feed() calls the stream_callbacks, so all stanzas in
 	-- the body are processed in this next line before it returns.
 	-- In particular, the streamopened() stream callback is where
@@ -206,6 +209,7 @@
 		return;
 	end
 	module:log("warn", "Unable to associate request with a session (incomplete request?)");
+	report_bad_sid();
 	local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 		["xmlns:stream"] = xmlns_streams, condition = "item-not-found" });
 	return tostring(close_reply) .. "\n";
@@ -221,7 +225,7 @@
 
 local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" };
 local function bosh_close_stream(session, reason)
-	(session.log or log)("info", "BOSH client disconnected: %s", tostring((reason and reason.condition or reason) or "session close"));
+	(session.log or log)("info", "BOSH client disconnected: %s", (reason and reason.condition or reason) or "session close");
 
 	local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 		["xmlns:stream"] = xmlns_streams });
@@ -232,6 +236,8 @@
 		if type(reason) == "string" then -- assume stream error
 			close_reply:tag("stream:error")
 				:tag(reason, {xmlns = xmlns_xmpp_streams});
+		elseif st.is_stanza(reason) then
+			close_reply = reason;
 		elseif type(reason) == "table" then
 			if reason.condition then
 				close_reply:tag("stream:error")
@@ -242,11 +248,9 @@
 				if reason.extra then
 					close_reply:add_child(reason.extra);
 				end
-			elseif reason.name then -- a stanza
-				close_reply = reason;
 			end
 		end
-		log("info", "Disconnecting client, <stream:error> is: %s", tostring(close_reply));
+		log("info", "Disconnecting client, <stream:error> is: %s", close_reply);
 	end
 
 	local response_body = tostring(close_reply);
@@ -269,9 +273,19 @@
 		-- New session request
 		context.notopen = nil; -- Signals that we accept this opening tag
 
+		if not attr.to then
+			log("debug", "BOSH client tried to connect without specifying a host");
+			report_bad_host();
+			local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
+				["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
+			response:send(tostring(close_reply));
+			return;
+		end
+
 		local to_host = nameprep(attr.to);
 		if not to_host then
-			log("debug", "BOSH client tried to connect to invalid host: %s", tostring(attr.to));
+			log("debug", "BOSH client tried to connect to invalid host: %s", attr.to);
+			report_bad_host();
 			local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 				["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
 			response:send(tostring(close_reply));
@@ -279,7 +293,8 @@
 		end
 
 		if not prosody.hosts[to_host] then
-			log("debug", "BOSH client tried to connect to non-existant host: %s", attr.to);
+			log("debug", "BOSH client tried to connect to non-existent host: %s", attr.to);
+			report_bad_host();
 			local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 				["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
 			response:send(tostring(close_reply));
@@ -288,6 +303,7 @@
 
 		if prosody.hosts[to_host].type ~= "local" then
 			log("debug", "BOSH client tried to connect to %s host: %s", prosody.hosts[to_host].type, attr.to);
+			report_bad_host();
 			local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 				["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
 			response:send(tostring(close_reply));
@@ -296,7 +312,7 @@
 
 		local wait = tonumber(attr.wait);
 		if not rid or (not attr.wait or not wait or wait < 0 or wait % 1 ~= 0) then
-			log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", tostring(attr.rid), tostring(attr.wait));
+			log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", attr.rid, attr.wait);
 			local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 				["xmlns:stream"] = xmlns_streams, condition = "bad-request" });
 			response:send(tostring(close_reply));
@@ -307,6 +323,7 @@
 
 		-- New session
 		sid = new_uuid();
+		-- TODO use util.session
 		local session = {
 			type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to,
 			rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1
@@ -327,6 +344,7 @@
 
 		session.log("debug", "BOSH session created for request from %s", session.ip);
 		log("info", "New BOSH session, assigned it sid '%s'", sid);
+		report_new_sid();
 
 		module:fire_event("bosh-session", { session = session, request = request });
 
@@ -341,7 +359,7 @@
 				s.attr.xmlns = "jabber:client";
 			end
 			s = filter("stanzas/out", s);
-			--log("debug", "Sending BOSH data: %s", tostring(s));
+			--log("debug", "Sending BOSH data: %s", s);
 			if not s then return true end
 			t_insert(session.send_buffer, tostring(s));
 
@@ -381,6 +399,7 @@
 	if not session then
 		-- Unknown sid
 		log("info", "Client tried to use sid '%s' which we don't know about", sid);
+		report_bad_sid();
 		response:send(tostring(st.stanza("body", { xmlns = xmlns_bosh, type = "terminate", condition = "item-not-found" })));
 		context.notopen = nil;
 		return;
@@ -443,7 +462,7 @@
 	end
 end
 
-local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(tostring(err), 2)); end
+local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(err, 2)); end
 
 function runner_callbacks:error(err) -- luacheck: ignore 212/self
 	return handleerr(err);
@@ -513,25 +532,33 @@
 	end
 end
 
-local GET_response = {
-	headers = {
-		content_type = "text/html";
-	};
-	body = [[<html><body>
-	<p>It works! Now point your BOSH client to this URL to connect to Prosody.</p>
-	<p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p>
-	</body></html>]];
-};
+local function GET_response(event)
+	return module:fire_event("http-message", {
+		response = event.response;
+		---
+		title = "Prosody BOSH endpoint";
+		message = "It works! Now point your BOSH client to this URL to connect to Prosody.";
+		warning = not (consider_bosh_secure or event.request.secure) and "This endpoint is not considered secure!" or nil;
+		-- <p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p>
+	}) or "This is the Prosody BOSH endpoint.";
+end
 
-module:depends("http");
-module:provides("http", {
-	default_path = "/http-bind";
-	route = {
-		["GET"] = GET_response;
-		["GET /"] = GET_response;
-		["OPTIONS"] = handle_OPTIONS;
-		["OPTIONS /"] = handle_OPTIONS;
-		["POST"] = handle_POST;
-		["POST /"] = handle_POST;
-	};
-});
+function module.add_host(module)
+	module:depends("http");
+	module:provides("http", {
+		default_path = "/http-bind";
+		cors = {
+			enabled = true;
+		};
+		route = {
+			["GET"] = GET_response;
+			["GET /"] = GET_response;
+			["POST"] = handle_POST;
+			["POST /"] = handle_POST;
+		};
+	});
+end
+
+if require"core.modulemanager".get_modules_for_host("*"):contains(module.name) then
+	module:add_host();
+end
--- a/plugins/mod_c2s.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_c2s.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,10 +12,12 @@
 local new_xmpp_stream = require "util.xmppstream".new;
 local nameprep = require "util.encodings".stringprep.nameprep;
 local sessionmanager = require "core.sessionmanager";
+local statsmanager = require "core.statsmanager";
 local st = require "util.stanza";
 local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session;
 local uuid_generate = require "util.uuid".generate;
-local runner = require "util.async".runner;
+local async = require "util.async";
+local runner = async.runner;
 
 local tostring, type = tostring, type;
 
@@ -28,8 +30,7 @@
 local opt_keepalives = module:get_option_boolean("c2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
 local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256);
 
-local measure_connections = module:measure("connections", "amount");
-local measure_ipv6 = module:measure("ipv6", "amount");
+local measure_connections = module:metric("gauge", "connections", "", "Established c2s connections", {"host", "type", "ip_family"});
 
 local sessions = module:shared("sessions");
 local core_process_stanza = prosody.core_process_stanza;
@@ -39,24 +40,46 @@
 local listener = {};
 local runner_callbacks = {};
 
+local m_tls_params = module:metric(
+	"counter", "encrypted", "",
+	"Encrypted connections",
+	{"protocol"; "cipher"}
+);
+
 module:hook("stats-update", function ()
-	local count = 0;
-	local ipv6 = 0;
+	-- for push backends, avoid sending out updates for each increment of
+	-- the metric below.
+	statsmanager.cork()
+	measure_connections:clear()
 	for _, session in pairs(sessions) do
-		count = count + 1;
-		if session.ip and session.ip:match(":") then
-			ipv6 = ipv6 + 1;
-		end
+		local host = session.host or ""
+		local type_ = session.type or "other"
+
+		-- we want to expose both v4 and v6 counters in all cases to make
+		-- queries smoother
+		local is_ipv6 = session.ip and session.ip:match(":") and 1 or 0
+		local is_ipv4 = 1 - is_ipv6
+		measure_connections:with_labels(host, type_, "ipv4"):add(is_ipv4)
+		measure_connections:with_labels(host, type_, "ipv6"):add(is_ipv6)
 	end
-	measure_connections(count);
-	measure_ipv6(ipv6);
+	statsmanager.uncork()
 end);
 
 --- Stream events handlers
 local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'};
 
 function stream_callbacks.streamopened(session, attr)
+	-- run _streamopened in async context
+	session.thread:run({ stream = "opened", attr = attr });
+end
+
+function stream_callbacks._streamopened(session, attr)
 	local send = session.send;
+	if not attr.to then
+		session:close{ condition = "improper-addressing",
+			text = "A 'to' attribute is required on stream headers" };
+		return;
+	end
 	local host = nameprep(attr.to);
 	if not host then
 		session:close{ condition = "improper-addressing",
@@ -80,7 +103,10 @@
 		return;
 	end
 
-	session:open_stream();
+	session:open_stream(host, attr.from);
+
+	-- Opening the stream can cause the stream to be closed
+	if session.destroyed then return end
 
 	(session.log or log)("debug", "Sent reply <stream:stream> to client");
 	session.notopen = nil;
@@ -92,13 +118,13 @@
 		session.encrypted = true;
 
 		local sock = session.conn:socket();
-		if sock.info then
-			local info = sock:info();
+		local info = sock.info and sock:info();
+		if type(info) == "table" then
 			(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
 			session.compressed = info.compression;
+			m_tls_params:with_labels(info.protocol, info.cipher):add(1)
 		else
 			(session.log or log)("info", "Stream encrypted");
-			session.compressed = sock.compression and sock:compression(); --COMPAT mw/luasec-hg
 		end
 	end
 
@@ -107,15 +133,23 @@
 	if features.tags[1] or session.full_jid then
 		send(features);
 	else
-		(session.log or log)("warn", "No stream features to offer");
-		session:close({
-			condition = "undefined-condition";
-			text = "No stream features to proceed with on "..(session.secure and "" or "in").."secure stream";
-		});
+		if session.secure then
+			-- Here SASL should be offered
+			(session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings.");
+		else
+			-- Normally STARTTLS would be offered
+			(session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings.");
+		end
+		session:close{ condition = "undefined-condition", text = "No stream features to proceed with" };
 	end
 end
 
-function stream_callbacks.streamclosed(session)
+function stream_callbacks.streamclosed(session, attr)
+	-- run _streamclosed in async context
+	session.thread:run({ stream = "closed", attr = attr });
+end
+
+function stream_callbacks._streamclosed(session)
 	session.log("debug", "Received </stream:stream>");
 	session:close(false);
 end
@@ -125,7 +159,7 @@
 		session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
 		session:close("invalid-namespace");
 	elseif error == "parse-error" then
-		(session.log or log)("debug", "Client XML parse error: %s", tostring(data));
+		(session.log or log)("debug", "Client XML parse error: %s", data);
 		session:close("not-well-formed");
 	elseif error == "stream-error" then
 		local condition, text = "undefined-condition";
@@ -153,6 +187,9 @@
 --- Session methods
 local function session_close(session, reason)
 	local log = session.log or log;
+	local close_event_payload = { session = session, reason = reason };
+	module:context(session.host):fire_event("pre-session-close", close_event_payload);
+	reason = close_event_payload.reason;
 	if session.conn then
 		if session.notopen then
 			session:open_stream();
@@ -161,6 +198,8 @@
 			local stream_error = st.stanza("stream:error");
 			if type(reason) == "string" then -- assume stream error
 				stream_error:tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' });
+			elseif st.is_stanza(reason) then
+				stream_error = reason;
 			elseif type(reason) == "table" then
 				if reason.condition then
 					stream_error:tag(reason.condition, stream_xmlns_attr):up();
@@ -170,8 +209,6 @@
 					if reason.extra then
 						stream_error:add_child(reason.extra);
 					end
-				elseif reason.name then -- a stanza
-					stream_error = reason;
 				end
 			end
 			stream_error = tostring(stream_error);
@@ -206,27 +243,25 @@
 	end
 end
 
-module:hook_global("user-deleted", function(event)
-	local username, host = event.username, event.host;
-	local user = hosts[host].sessions[username];
-	if user and user.sessions then
-		for _, session in pairs(user.sessions) do
-			session:close{ condition = "not-authorized", text = "Account deleted" };
-		end
-	end
-end, 200);
-
-module:hook_global("user-password-changed", function(event)
-	local username, host, resource = event.username, event.host, event.resource;
-	local user = hosts[host].sessions[username];
-	if user and user.sessions then
-		for r, session in pairs(user.sessions) do
-			if r ~= resource then
-				session:close{ condition = "reset", text = "Password changed" };
+-- Close all user sessions with the specified reason. If leave_resource is
+-- true, the resource named by event.resource will not be closed.
+local function disconnect_user_sessions(reason, leave_resource)
+	return function (event)
+		local username, host, resource = event.username, event.host, event.resource;
+		local user = hosts[host].sessions[username];
+		if user and user.sessions then
+			for r, session in pairs(user.sessions) do
+				if not leave_resource or r ~= resource then
+					session:close(reason);
+				end
 			end
 		end
 	end
-end, 200);
+end
+
+module:hook_global("user-password-changed", disconnect_user_sessions({ condition = "reset", text = "Password changed" }, true), 200);
+module:hook_global("user-roles-changed", disconnect_user_sessions({ condition = "reset", text = "Roles changed" }), 200);
+module:hook_global("user-deleted", disconnect_user_sessions({ condition = "not-authorized", text = "Account deleted" }), 200);
 
 function runner_callbacks:ready()
 	if self.data.conn then
@@ -254,17 +289,20 @@
 
 	session.log("info", "Client connected");
 
-	-- Client is using legacy SSL (otherwise mod_tls sets this flag)
+	-- Client is using Direct TLS or legacy SSL (otherwise mod_tls sets this flag)
 	if conn:ssl() then
 		session.secure = true;
 		session.encrypted = true;
 
 		-- Check if TLS compression is used
 		local sock = conn:socket();
-		if sock.info then
-			session.compressed = sock:info"compression";
-		elseif sock.compression then
-			session.compressed = sock:compression(); --COMPAT mw/luasec-hg
+		local info = sock.info and sock:info();
+		if type(info) == "table" then
+			(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
+			session.compressed = info.compression;
+			m_tls_params:with_labels(info.protocol, info.cipher):add(1)
+		else
+			(session.log or log)("info", "Stream encrypted");
 		end
 	end
 
@@ -284,7 +322,13 @@
 	end
 
 	session.thread = runner(function (stanza)
-		core_process_stanza(session, stanza);
+		if st.is_stanza(stanza) then
+			core_process_stanza(session, stanza);
+		elseif stanza.stream == "opened" then
+			stream_callbacks._streamopened(session, stanza.attr);
+		elseif stanza.stream == "closed" then
+			stream_callbacks._streamclosed(session, stanza.attr);
+		end
 	end, runner_callbacks, session);
 
 	local filter = session.filter;
@@ -295,8 +339,16 @@
 			if data then
 				local ok, err = stream:feed(data);
 				if not ok then
-					log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_"));
-					session:close("not-well-formed");
+					log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
+					if err == "stanza-too-large" then
+						session:close({
+							condition = "policy-violation",
+							text = "XML stanza is too big",
+							extra = st.stanza("stanza-too-big", { xmlns = 'urn:xmpp:errors' }),
+						});
+					else
+						session:close("not-well-formed");
+					end
 				end
 			end
 		end
@@ -305,6 +357,7 @@
 	if c2s_timeout then
 		add_task(c2s_timeout, function ()
 			if session.type == "c2s_unauthed" then
+				(session.log or log)("debug", "Connection still not authenticated after c2s_timeout=%gs, closing it", c2s_timeout);
 				session:close("connection-timeout");
 			end
 		end);
@@ -330,6 +383,7 @@
 		session.conn = nil;
 		sessions[conn]  = nil;
 	end
+	module:fire_event("c2s-closed", { session = session; conn = conn });
 end
 
 function listener.onreadtimeout(conn)
@@ -339,6 +393,20 @@
 	end
 end
 
+function listener.ondrain(conn)
+	local session = sessions[conn];
+	if session then
+		return (hosts[session.host] or prosody).events.fire_event("c2s-ondrain", { session = session });
+	end
+end
+
+function listener.onpredrain(conn)
+	local session = sessions[conn];
+	if session then
+		return (hosts[session.host] or prosody).events.fire_event("c2s-pre-ondrain", { session = session });
+	end
+end
+
 local function keepalive(event)
 	local session = event.session;
 	if not session.notopen then
@@ -356,11 +424,33 @@
 
 module:hook("c2s-read-timeout", keepalive, -1);
 
+module:hook("server-stopping", function(event) -- luacheck: ignore 212/event
+	-- Close ports
+	local pm = require "core.portmanager";
+	for _, netservice in pairs(module.items["net-provider"]) do
+		pm.unregister_service(netservice.name, netservice);
+	end
+end, -80);
+
 module:hook("server-stopping", function(event)
+	local wait, done = async.waiter(1, true);
+	module:hook("c2s-closed", function ()
+		if next(sessions) == nil then done(); end
+	end)
+
+	-- Close sessions
 	local reason = event.reason;
 	for _, session in pairs(sessions) do
 		session:close{ condition = "system-shutdown", text = reason };
 	end
+
+	-- Wait for them to close properly if they haven't already
+	if next(sessions) ~= nil then
+		add_task(stream_close_timeout+1, function () done() end);
+		module:log("info", "Waiting for sessions to close");
+		wait();
+	end
+
 end, -100);
 
 
@@ -371,11 +461,22 @@
 	default_port = 5222;
 	encryption = "starttls";
 	multiplex = {
+		protocol = "xmpp-client";
 		pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>";
 	};
 });
 
 module:provides("net", {
+	name = "c2s_direct_tls";
+	listener = listener;
+	encryption = "ssl";
+	multiplex = {
+		pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:client%1.*>";
+	};
+});
+
+-- COMPAT
+module:provides("net", {
 	name = "legacy_ssl";
 	listener = listener;
 	encryption = "ssl";
--- a/plugins/mod_carbons.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_carbons.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -5,10 +5,17 @@
 
 local st = require "util.stanza";
 local jid_bare = require "util.jid".bare;
+local jid_resource = require "util.jid".resource;
 local xmlns_carbons = "urn:xmpp:carbons:2";
 local xmlns_forward = "urn:xmpp:forward:0";
 local full_sessions, bare_sessions = prosody.full_sessions, prosody.bare_sessions;
 
+module:add_feature("urn:xmpp:carbons:rules:0");
+
+local function is_bare(jid)
+	return not jid_resource(jid);
+end
+
 local function toggle_carbons(event)
 	local origin, stanza = event.origin, event.stanza;
 	local state = stanza.tags[1].name;
@@ -20,6 +27,54 @@
 module:hook("iq-set/self/"..xmlns_carbons..":disable", toggle_carbons);
 module:hook("iq-set/self/"..xmlns_carbons..":enable", toggle_carbons);
 
+local function should_copy(stanza, c2s, user_bare) --> boolean, reason: string
+	local st_type = stanza.attr.type or "normal";
+	if stanza:get_child("private", xmlns_carbons) then
+		return false, "private";
+	end
+
+	if stanza:get_child("no-copy", "urn:xmpp:hints") then
+		return false, "hint";
+	end
+
+	if not c2s and stanza.attr.to ~= user_bare and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then
+		-- MUC PMs are normally sent to full JIDs
+		return false, "muc-pm";
+	end
+
+	if st_type == "chat" then
+		return true, "type";
+	end
+
+	if st_type == "normal" and stanza:get_child("body") then
+		return true, "type";
+	end
+
+	-- Normal outgoing chat messages are sent to=bare JID. This clause should
+	-- match the error bounces from those, which would have from=bare JID and
+	-- be incoming (not c2s).
+	if st_type == "error" and not c2s and is_bare(stanza.attr.from) then
+		return true, "bounce";
+	end
+
+	if stanza:get_child(nil, "urn:xmpp:jingle-message:0") or stanza:get_child(nil, "urn:xmpp:jingle-message:1") then
+		-- XXX Experimental XEP
+		return true, "jingle call";
+	end
+
+	if stanza:get_child_with_attr("stanza-id", "urn:xmpp:sid:0", "by", user_bare) then
+		return true, "archived";
+	end
+
+	return false, "default";
+end
+
+module:hook("carbons-should-copy", function (event)
+	local should, why = should_copy(event.stanza);
+	event.reason = why;
+	return should;
+end, -1)
+
 local function message_handler(event, c2s)
 	local origin, stanza = event.origin, event.stanza;
 	local orig_type = stanza.attr.type or "normal";
@@ -28,10 +83,6 @@
 	local orig_to = stanza.attr.to;
 	local bare_to = jid_bare(orig_to);
 
-	if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body"))) then
-		return -- Only chat type messages
-	end
-
 	-- Stanza sent by a local client
 	local bare_jid = bare_from; -- JID of the local user
 	local target_session = origin;
@@ -56,35 +107,23 @@
 		return -- No use in sending carbons to an offline user
 	end
 
-	if stanza:get_child("private", xmlns_carbons) then
-		if not c2s then
+	local event_payload = { stanza = stanza; session = origin };
+	local should = module:fire_event("carbons-should-copy", event_payload);
+	local why = event_payload.reason;
+
+	if not should then
+		module:log("debug", "Not copying stanza: %s (%s)", stanza:top_tag(), why);
+		if why == "private" and not c2s then
 			stanza:maptags(function(tag)
 				if not ( tag.attr.xmlns == xmlns_carbons and tag.name == "private" ) then
 					return tag;
 				end
 			end);
 		end
-		module:log("debug", "Message tagged private, ignoring");
-		return
-	elseif stanza:get_child("no-copy", "urn:xmpp:hints") then
-		module:log("debug", "Message has no-copy hint, ignoring");
-		return
-	elseif not c2s and bare_jid ~= orig_to and stanza:get_child("x", "http://jabber.org/protocol/muc#user") then
-		module:log("debug", "MUC PM, ignoring");
-		return
+		return;
 	end
 
-	-- Create the carbon copy and wrap it as per the Stanza Forwarding XEP
-	local copy = st.clone(stanza);
-	if c2s and not orig_to then
-		stanza.attr.to = bare_from;
-	end
-	copy.attr.xmlns = "jabber:client";
-	local carbon = st.message{ from = bare_jid, type = orig_type, }
-		:tag(c2s and "sent" or "received", { xmlns = xmlns_carbons })
-			:tag("forwarded", { xmlns = xmlns_forward })
-				:add_child(copy):reset();
-
+	local carbon;
 	user_sessions = user_sessions and user_sessions.sessions;
 	for _, session in pairs(user_sessions) do
 		-- Carbons are sent to resources that have enabled it
@@ -93,6 +132,20 @@
 		and session ~= target_session
 		-- and isn't among the top resources that would receive the message per standard routing rules
 		and (c2s or session.priority ~= top_priority) then
+			if not carbon then
+				-- Create the carbon copy and wrap it as per the Stanza Forwarding XEP
+				local copy = st.clone(stanza);
+				if c2s and not orig_to then
+					stanza.attr.to = bare_from;
+				end
+				copy.attr.xmlns = "jabber:client";
+				carbon = st.message{ from = bare_jid, type = orig_type, }
+					:tag(c2s and "sent" or "received", { xmlns = xmlns_carbons })
+						:tag("forwarded", { xmlns = xmlns_forward })
+							:add_child(copy):reset();
+
+			end
+
 			carbon.attr.to = session.full_jid;
 			module:log("debug", "Sending carbon to %s", session.full_jid);
 			session.send(carbon);
--- a/plugins/mod_component.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_component.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -50,6 +50,7 @@
 	local send;
 
 	local function on_destroy(session, err) --luacheck: ignore 212/err
+		module:set_status("warn", err and ("Disconnected: "..err) or "Disconnected");
 		env.connected = false;
 		env.session = false;
 		send = nil;
@@ -103,6 +104,7 @@
 		module:log("info", "External component successfully authenticated");
 		session.send(st.stanza("handshake"));
 		module:fire_event("component-authenticated", { session = session });
+		module:set_status("info", "Connected");
 
 		return true;
 	end
@@ -131,7 +133,8 @@
 			end
 			module:log("warn", "Component not connected, bouncing error for: %s", stanza:top_tag());
 			if stanza.attr.type ~= "error" and stanza.attr.type ~= "result" then
-				event.origin.send(st.error_reply(stanza, "wait", "service-unavailable", "Component unavailable"));
+				event.origin.send(st.error_reply(stanza, "wait", "remote-server-timeout", "Component unavailable", module.host)
+					:tag("not-connected", { xmlns = "xmpp:prosody.im/protocol/component" }));
 			end
 		end
 		return true;
@@ -166,11 +169,11 @@
 
 function stream_callbacks.error(session, error, data)
 	if session.destroyed then return; end
-	module:log("warn", "Error processing component stream: %s", tostring(error));
+	module:log("warn", "Error processing component stream: %s", error);
 	if error == "no-stream" then
 		session:close("invalid-namespace");
 	elseif error == "parse-error" then
-		session.log("warn", "External component %s XML parse error: %s", tostring(session.host), tostring(data));
+		session.log("warn", "External component %s XML parse error: %s", session.host, data);
 		session:close("not-well-formed");
 	elseif error == "stream-error" then
 		local condition, text = "undefined-condition";
@@ -191,6 +194,10 @@
 end
 
 function stream_callbacks.streamopened(session, attr)
+	if not attr.to then
+		session:close{ condition = "improper-addressing", text = "A 'to' attribute is required on stream headers" };
+		return;
+	end
 	if not hosts[attr.to] or not hosts[attr.to].modules.component then
 		session:close{ condition = "host-unknown", text = tostring(attr.to).." does not match any configured external components" };
 		return;
@@ -207,7 +214,7 @@
 	session:close();
 end
 
-local function handleerr(err) log("error", "Traceback[component]: %s", traceback(tostring(err), 2)); end
+local function handleerr(err) log("error", "Traceback[component]: %s", traceback(err, 2)); end
 function stream_callbacks.handlestanza(session, stanza)
 	-- Namespaces are icky.
 	if not stanza.attr.xmlns and stanza.name == "handshake" then
@@ -258,6 +265,9 @@
 			if type(reason) == "string" then -- assume stream error
 				module:log("info", "Disconnecting component, <stream:error> is: %s", reason);
 				session.send(st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' }));
+			elseif st.is_stanza(reason) then
+				module:log("info", "Disconnecting component, <stream:error> is: %s", reason);
+				session.send(reason);
 			elseif type(reason) == "table" then
 				if reason.condition then
 					local stanza = st.stanza("stream:error"):tag(reason.condition, stream_xmlns_attr):up();
@@ -267,11 +277,8 @@
 					if reason.extra then
 						stanza:add_child(reason.extra);
 					end
-					module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(stanza));
+					module:log("info", "Disconnecting component, <stream:error> is: %s", stanza);
 					session.send(stanza);
-				elseif reason.name then -- a stanza
-					module:log("info", "Disconnecting component, <stream:error> is: %s", tostring(reason));
-					session.send(reason);
 				end
 			end
 		end
@@ -311,7 +318,7 @@
 	function session.data(_, data)
 		local ok, err = stream:feed(data);
 		if ok then return; end
-		module:log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_"));
+		log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
 		session:close("not-well-formed");
 	end
 
@@ -326,7 +333,7 @@
 function listener.ondisconnect(conn, err)
 	local session = sessions[conn];
 	if session then
-		(session.log or log)("info", "component disconnected: %s (%s)", tostring(session.host), tostring(err));
+		(session.log or log)("info", "component disconnected: %s (%s)", session.host, err);
 		if session.host then
 			module:context(session.host):fire_event("component-disconnected", { session = session, reason = err });
 		end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_cron.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,65 @@
+module:set_global();
+
+local async = require("util.async");
+local datetime = require("util.datetime");
+
+local periods = { hourly = 3600; daily = 86400; weekly = 7 * 86400 }
+
+local active_hosts = {}
+
+function module.add_host(host_module)
+
+	local last_run_times = host_module:open_store("cron", "map");
+	active_hosts[host_module.host] = true;
+
+	local function save_task(task, started_at) last_run_times:set(nil, task.id, started_at); end
+
+	local function task_added(event)
+		local task = event.item;
+		if task.name == nil then task.name = task.when; end
+		if task.id == nil then task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower(); end
+		if task.last == nil then task.last = last_run_times:get(nil, task.id); end
+		task.save = save_task;
+		module:log("debug", "%s task %s added, last run %s", task.when, task.id,
+			task.last and datetime.datetime(task.last) or "never");
+		if task.last == nil then
+			local now = os.time();
+			task.last = now - now % periods[task.when];
+		end
+		return true
+	end
+
+	local function task_removed(event)
+		local task = event.item;
+		host_module:log("debug", "Task %s removed", task.id);
+		return true
+	end
+
+	host_module:handle_items("task", task_added, task_removed, true);
+
+	function host_module.unload() active_hosts[host_module.host] = nil; end
+end
+
+local function should_run(when, last) return not last or last + periods[when] * 0.995 <= os.time() end
+
+local function run_task(task)
+	local started_at = os.time();
+	task:run(started_at);
+	task.last = started_at;
+	task:save(started_at);
+end
+
+local task_runner = async.runner(run_task);
+scheduled = module:add_timer(1, function()
+	module:log("info", "Running periodic tasks");
+	local delay = 3600;
+	for host in pairs(active_hosts) do
+		module:log("debug", "Running periodic tasks for host %s", host);
+		for _, task in ipairs(module:context(host):get_host_items("task")) do
+			module:log("debug", "Considering %s task %s (%s)", task.when, task.id, task.run);
+			if should_run(task.when, task.last) then task_runner:run(task); end
+		end
+	end
+	module:log("debug", "Wait %ds", delay);
+	return delay
+end);
--- a/plugins/mod_csi.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_csi.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,8 +2,9 @@
 local xmlns_csi = "urn:xmpp:csi:0";
 local csi_feature = st.stanza("csi", { xmlns = xmlns_csi });
 
+local csi_handler_available = nil;
 module:hook("stream-features", function (event)
-	if event.origin.username then
+	if event.origin.username and csi_handler_available then
 		event.features:add_child(csi_feature);
 	end
 end);
@@ -21,3 +22,14 @@
 module:hook("stanza/"..xmlns_csi..":active", refire_event("csi-client-active"));
 module:hook("stanza/"..xmlns_csi..":inactive", refire_event("csi-client-inactive"));
 
+function module.load()
+	if prosody.hosts[module.host].events._handlers["csi-client-active"] then
+		csi_handler_available = true;
+		module:set_status("core", "CSI handler module loaded");
+	else
+		csi_handler_available = false;
+		module:set_status("warn", "No CSI handler module loaded");
+	end
+end
+module:hook("module-loaded", module.load);
+module:hook("module-unloaded", module.load);
--- a/plugins/mod_csi_simple.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_csi_simple.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,4 +1,4 @@
--- Copyright (C) 2016-2018 Kim Alvefur
+-- Copyright (C) 2016-2020 Kim Alvefur
 --
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
@@ -9,115 +9,267 @@
 local jid = require "util.jid";
 local st = require "util.stanza";
 local dt = require "util.datetime";
-local new_queue = require "util.queue".new;
-
-local function new_pump(output, ...)
-	-- luacheck: ignore 212/self
-	local q = new_queue(...);
-	local flush = true;
-	function q:pause()
-		flush = false;
-	end
-	function q:resume()
-		flush = true;
-		return q:flush();
-	end
-	local push = q.push;
-	function q:push(item)
-		local ok = push(self, item);
-		if not ok then
-			q:flush();
-			output(item, self);
-		elseif flush then
-			return q:flush();
-		end
-		return true;
-	end
-	function q:flush()
-		local item = self:pop();
-		while item do
-			output(item, self);
-			item = self:pop();
-		end
-		return true;
-	end
-	return q;
-end
+local filters = require "util.filters";
+local timer = require "util.timer";
 
 local queue_size = module:get_option_number("csi_queue_size", 256);
+local resume_delay = module:get_option_number("csi_resume_inactive_delay", 5);
 
-module:hook("csi-is-stanza-important", function (event)
-	local stanza = event.stanza;
-	if not st.is_stanza(stanza) then
-		return true;
+local important_payloads = module:get_option_set("csi_important_payloads", { });
+
+function is_important(stanza) --> boolean, reason: string
+	if stanza == " " then
+		return true, "whitespace keepalive";
+	elseif type(stanza) == "string" then
+		return true, "raw data";
+	elseif not st.is_stanza(stanza) then
+		-- This should probably never happen
+		return true, type(stanza);
+	end
+	if stanza.attr.xmlns ~= nil then
+		-- stream errors, stream management etc
+		return true, "nonza";
 	end
 	local st_name = stanza.name;
 	if not st_name then return false; end
 	local st_type = stanza.attr.type;
 	if st_name == "presence" then
-		if st_type == nil or st_type == "unavailable" then
-			return false;
+		if st_type == nil or st_type == "unavailable" or st_type == "error" then
+			return false, "presence update";
 		end
-		return true;
+		-- TODO Some MUC awareness, e.g. check for the 'this relates to you' status code
+		return true, "subscription request";
 	elseif st_name == "message" then
 		if st_type == "headline" then
-			return false;
+			-- Headline messages are ephemeral by definition
+			return false, "headline";
+		end
+		if st_type == "error" then
+			return true, "delivery failure";
 		end
 		if stanza:get_child("sent", "urn:xmpp:carbons:2") then
-			return true;
+			return true, "carbon";
 		end
 		local forwarded = stanza:find("{urn:xmpp:carbons:2}received/{urn:xmpp:forward:0}/{jabber:client}message");
 		if forwarded then
 			stanza = forwarded;
 		end
 		if stanza:get_child("body") then
-			return true;
+			return true, "body";
 		end
 		if stanza:get_child("subject") then
-			return true;
+			-- Last step of a MUC join
+			return true, "subject";
 		end
 		if stanza:get_child("encryption", "urn:xmpp:eme:0") then
-			return true;
+			-- Since we can't know what an encrypted message contains, we assume it's important
+			-- XXX Experimental XEP
+			return true, "encrypted";
+		end
+		if stanza:get_child("x", "jabber:x:conference") or stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then
+			return true, "invite";
 		end
-		if stanza:get_child(nil, "urn:xmpp:jingle-message:0") then
-			return true;
+		if stanza:get_child(nil, "urn:xmpp:jingle-message:0") or stanza:get_child(nil, "urn:xmpp:jingle-message:1") then
+			-- XXX Experimental XEP
+			return true, "jingle call";
+		end
+		for important in important_payloads do
+			if stanza:find(important) then
+				return true;
+			end
 		end
 		return false;
+	elseif st_name == "iq" then
+		return true;
 	end
-	return true;
+end
+
+module:hook("csi-is-stanza-important", function (event)
+	local important, why = is_important(event.stanza);
+	event.reason = why;
+	return important;
 end, -1);
 
+local function should_flush(stanza, session, ctr) --> boolean, reason: string
+	if ctr >= queue_size then
+		return true, "queue size limit reached";
+	end
+	local event = { stanza = stanza, session = session };
+	local ret = module:fire_event("csi-is-stanza-important", event)
+	return ret, event.reason;
+end
+
+local function with_timestamp(stanza, from)
+	if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then
+		stanza = st.clone(stanza);
+		stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = from, stamp = dt.datetime()}));
+	end
+	return stanza;
+end
+
+local measure_buffer_hold = module:measure("buffer_hold", "times",
+	{ buckets = { 0.1; 1; 5; 10; 15; 30; 60; 120; 180; 300; 600; 900 } });
+
+local flush_reasons = module:metric(
+	"counter", "flushes", "",
+	"CSI queue flushes",
+	{ "reason" }
+);
+
+local function manage_buffer(stanza, session)
+	local ctr = session.csi_counter or 0;
+	if session.state ~= "inactive" then
+		session.csi_counter = ctr + 1;
+		return stanza;
+	end
+	local flush, why = should_flush(stanza, session, ctr);
+	if flush then
+		if session.csi_measure_buffer_hold then
+			session.csi_measure_buffer_hold();
+			session.csi_measure_buffer_hold = nil;
+		end
+		flush_reasons:with_labels(why or "important"):add(1);
+		session.log("debug", "Flushing buffer (%s; queue size is %d)", why or "important", session.csi_counter);
+		session.state = "flushing";
+		module:fire_event("csi-flushing", { session = session });
+		session.conn:resume_writes();
+	else
+		session.log("debug", "Holding buffer (%s; queue size is %d)", why or "unimportant", session.csi_counter);
+		stanza = with_timestamp(stanza, jid.join(session.username, session.host))
+	end
+	session.csi_counter = ctr + 1;
+	return stanza;
+end
+
+local function flush_buffer(data, session)
+	local ctr = session.csi_counter or 0;
+	if ctr == 0 or session.state ~= "inactive" then return data end
+	session.log("debug", "Flushing buffer (%s; queue size is %d)", "client activity", session.csi_counter);
+	session.state = "flushing";
+	module:fire_event("csi-flushing", { session = session });
+	flush_reasons:with_labels("client activity"):add(1);
+	if session.csi_measure_buffer_hold then
+		session.csi_measure_buffer_hold();
+		session.csi_measure_buffer_hold = nil;
+	end
+	session.conn:resume_writes();
+	return data;
+end
+
+function enable_optimizations(session)
+	if session.conn and session.conn.pause_writes then
+		session.conn:pause_writes();
+		session.csi_measure_buffer_hold = measure_buffer_hold();
+		session.csi_counter = 0;
+		filters.add_filter(session, "stanzas/out", manage_buffer);
+		filters.add_filter(session, "bytes/in", flush_buffer);
+	else
+		session.log("warn", "Session connection does not support write pausing");
+	end
+end
+
+function disable_optimizations(session)
+	filters.remove_filter(session, "stanzas/out", manage_buffer);
+	filters.remove_filter(session, "bytes/in", flush_buffer);
+	session.csi_counter = nil;
+	if session.csi_measure_buffer_hold then
+		session.csi_measure_buffer_hold();
+		session.csi_measure_buffer_hold = nil;
+	end
+	if session.conn and session.conn.resume_writes then
+		session.conn:resume_writes();
+	end
+end
+
 module:hook("csi-client-inactive", function (event)
 	local session = event.origin;
-	if session.pump then
-		session.pump:pause();
-	else
-		local bare_jid = jid.join(session.username, session.host);
-		local send = session.send;
-		session._orig_send = send;
-		local pump = new_pump(session.send, queue_size);
-		pump:pause();
-		session.pump = pump;
-		function session.send(stanza)
-			if session.state == "active" or module:fire_event("csi-is-stanza-important", { stanza = stanza, session = session }) then
-				pump:flush();
-				send(stanza);
-			else
-				if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then
-					stanza = st.clone(stanza);
-					stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = bare_jid, stamp = dt.datetime()}));
-				end
-				pump:push(stanza);
-			end
-			return true;
-		end
-	end
+	enable_optimizations(session);
 end);
 
 module:hook("csi-client-active", function (event)
 	local session = event.origin;
-	if session.pump then
-		session.pump:resume();
+	disable_optimizations(session);
+end);
+
+module:hook("pre-resource-unbind", function (event)
+	local session = event.session;
+	disable_optimizations(session);
+end, 1);
+
+local function resume_optimizations(_, _, session)
+	if (session.state == "flushing" or session.state == "inactive")  and session.conn and session.conn.pause_writes then
+		session.state = "inactive";
+		session.conn:pause_writes();
+		session.csi_measure_buffer_hold = measure_buffer_hold();
+		session.log("debug", "Buffer flushed, resuming inactive mode (queue size was %d)", session.csi_counter);
+		session.csi_counter = 0;
+	end
+	session.csi_resume = nil;
+end
+
+module:hook("c2s-ondrain", function (event)
+	local session = event.session;
+	if (session.state == "flushing" or session.state == "inactive")  and session.conn and session.conn.pause_writes then
+		-- After flushing, remain in pseudo-flushing state for a moment to allow
+		-- some followup traffic, iq replies, smacks acks to be sent without having
+		-- to go back and forth between inactive and flush mode.
+		if not session.csi_resume then
+			session.csi_resume = timer.add_task(resume_delay, resume_optimizations, session);
+		end
+		-- Should further noise in this short grace period push back the delay?
+		-- Probably not great if the session can be kept in pseudo-active mode
+		-- indefinitely.
 	end
 end);
 
+function module.load()
+	for _, user_session in pairs(prosody.hosts[module.host].sessions) do
+		for _, session in pairs(user_session.sessions) do
+			if session.state == "inactive" then
+				enable_optimizations(session);
+			end
+		end
+	end
+end
+
+function module.unload()
+	for _, user_session in pairs(prosody.hosts[module.host].sessions) do
+		for _, session in pairs(user_session.sessions) do
+			if session.state and session.state ~= "active" then
+				disable_optimizations(session);
+			end
+		end
+	end
+end
+
+function module.command(arg)
+	if arg[1] ~= "test" then
+		print("Usage: "..module.name.." test < test-stream.xml")
+		print("");
+		print("Provide a series of stanzas to test against importance algorithm");
+		return 1;
+	end
+	-- luacheck: ignore 212/self
+	local xmppstream = require "util.xmppstream";
+	local input_session = { notopen = true }
+	local stream_callbacks = { stream_ns = "jabber:client", default_ns = "jabber:client" };
+	function stream_callbacks:handlestanza(stanza)
+		local important, because = is_important(stanza);
+		print("--");
+		print(stanza:indent(nil, "  "));
+		-- :pretty_print() maybe?
+		if important then
+			print((because or "unspecified reason").. " -> important");
+		else
+			print((because or "unspecified reason").. " -> unimportant");
+		end
+	end
+	local input_stream = xmppstream.new(input_session, stream_callbacks);
+	input_stream:reset();
+	input_stream:feed(st.stanza("stream", { xmlns = "jabber:client" }):top_tag());
+	input_session.notopen = nil;
+
+	for line in io.lines() do
+		input_stream:feed(line);
+	end
+end
--- a/plugins/mod_dialback.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_dialback.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -77,9 +77,14 @@
 	local origin, stanza = event.origin, event.stanza;
 
 	if origin.type == "s2sin_unauthed" or origin.type == "s2sin" then
-		-- he wants to be identified through dialback
+		-- They want to be identified through dialback
 		-- We need to check the key with the Authoritative server
 		local attr = stanza.attr;
+		if not attr.to or not attr.from then
+			origin.log("debug", "Missing Dialback addressing (from=%q, to=%q)", attr.from, attr.to);
+			origin:close("improper-addressing");
+			return true;
+		end
 		local to, from = nameprep(attr.to), nameprep(attr.from);
 
 		if not hosts[to] then
@@ -89,6 +94,7 @@
 			return true;
 		elseif not from then
 			origin:close("improper-addressing");
+			return true;
 		end
 
 
@@ -123,7 +129,7 @@
 		if dialback_verifying and attr.from == origin.to_host then
 			local valid;
 			if attr.type == "valid" then
-				module:fire_event("s2s-authenticated", { session = dialback_verifying, host = attr.from });
+				module:fire_event("s2s-authenticated", { session = dialback_verifying, host = attr.from, mechanism = "dialback" });
 				valid = "valid";
 			else
 				-- Warn the original connection that is was not verified successfully
@@ -160,7 +166,7 @@
 			return true;
 		end
 		if stanza.attr.type == "valid" then
-			module:fire_event("s2s-authenticated", { session = origin, host = attr.from });
+			module:fire_event("s2s-authenticated", { session = origin, host = attr.from, mechanism = "dialback" });
 		else
 			origin:close("not-authorized", "dialback authentication failed");
 		end
--- a/plugins/mod_disco.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_disco.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,11 +8,14 @@
 
 local get_children = require "core.hostmanager".get_children;
 local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
+local um_is_admin = require "core.usermanager".is_admin;
 local jid_split = require "util.jid".split;
 local jid_bare = require "util.jid".bare;
 local st = require "util.stanza"
 local calculate_hash = require "util.caps".calculate_hash;
 
+local expose_admins = module:get_option_boolean("disco_expose_admins", false);
+
 local disco_items = module:get_option_array("disco_items", {})
 do -- validate disco_items
 	for _, item in ipairs(disco_items) do
@@ -33,7 +36,7 @@
 end
 
 if module:get_host_type() == "local" then
-	module:add_identity("server", "im", module:get_option_string("name", "Prosody")); -- FIXME should be in the non-existing mod_router
+	module:add_identity("server", "im", module:get_option_string("name", "Prosody")); -- FIXME should be in the nonexisting mod_router
 end
 module:add_feature("http://jabber.org/protocol/disco#info");
 module:add_feature("http://jabber.org/protocol/disco#items");
@@ -71,6 +74,7 @@
 		ver = _cached_server_caps_hash;
 	});
 end
+
 local function clear_disco_cache()
 	_cached_server_disco_info, _cached_server_caps_feature, _cached_server_caps_hash = nil, nil, nil;
 end
@@ -116,6 +120,7 @@
 	origin.send(reply);
 	return true;
 end);
+
 module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local node = stanza.tags[1].attr.node;
@@ -151,13 +156,20 @@
 	end
 end);
 
+module:hook("s2s-stream-features", function (event)
+	if event.origin.type == "s2sin" then
+		event.features:add_child(get_server_caps_feature());
+	end
+end);
+
 -- Handle disco requests to user accounts
 if module:get_host_type() ~= "local" then	return end -- skip for components
 module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local node = stanza.tags[1].attr.node;
 	local username = jid_split(stanza.attr.to) or origin.username;
-	if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
+	local is_admin = um_is_admin(stanza.attr.to or origin.full_jid, module.host)
+	if not stanza.attr.to or (expose_admins and is_admin) or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
 		if node and node ~= "" then
 			local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info', node=node});
 			if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
@@ -173,12 +185,19 @@
 		end
 		local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info'});
 		if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
-		reply:tag('identity', {category='account', type='registered'}):up();
+		if is_admin then
+			reply:tag('identity', {category='account', type='admin'}):up();
+		elseif prosody.hosts[module.host].users.name == "anonymous" then
+			reply:tag('identity', {category='account', type='anonymous'}):up();
+		else
+			reply:tag('identity', {category='account', type='registered'}):up();
+		end
 		module:fire_event("account-disco-info", { origin = origin, reply = reply });
 		origin.send(reply);
 		return true;
 	end
 end);
+
 module:hook("iq-get/bare/http://jabber.org/protocol/disco#items:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local node = stanza.tags[1].attr.node;
@@ -204,3 +223,4 @@
 		return true;
 	end
 end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_external_services.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,243 @@
+
+local dt = require "util.datetime";
+local base64 = require "util.encodings".base64;
+local hashes = require "util.hashes";
+local st = require "util.stanza";
+local jid = require "util.jid";
+local array = require "util.array";
+local set = require "util.set";
+
+local default_host = module:get_option_string("external_service_host", module.host);
+local default_port = module:get_option_number("external_service_port");
+local default_secret = module:get_option_string("external_service_secret");
+local default_ttl = module:get_option_number("external_service_ttl", 86400);
+
+local configured_services = module:get_option_array("external_services", {});
+
+local access = module:get_option_set("external_service_access", {});
+
+-- https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
+local function behave_turn_rest_credentials(srv, item, secret)
+	local ttl = default_ttl;
+	if type(item.ttl) == "number" then
+		ttl = item.ttl;
+	end
+	local expires = srv.expires or os.time() + ttl;
+	local username;
+	if type(item.username) == "string" then
+		username = string.format("%d:%s", expires, item.username);
+	else
+		username = string.format("%d", expires);
+	end
+	srv.username = username;
+	srv.password = base64.encode(hashes.hmac_sha1(secret, srv.username));
+end
+
+local algorithms = {
+	turn = behave_turn_rest_credentials;
+}
+
+-- filter config into well-defined service records
+local function prepare(item)
+	if type(item) ~= "table" then
+		module:log("error", "Service definition is not a table: %q", item);
+		return nil;
+	end
+
+	local srv = {
+		type = nil;
+		transport = nil;
+		host = default_host;
+		port = default_port;
+		username = nil;
+		password = nil;
+		restricted = nil;
+		expires = nil;
+	};
+
+	if type(item.type) == "string" then
+		srv.type = item.type;
+	else
+		module:log("error", "Service missing mandatory 'type' field: %q", item);
+		return nil;
+	end
+	if type(item.transport) == "string" then
+		srv.transport = item.transport;
+	else
+		module:log("warn", "Service missing recommended 'transport' field: %q", item);
+	end
+	if type(item.host) == "string" then
+		srv.host = item.host;
+	end
+	if type(item.port) == "number" then
+		srv.port = item.port;
+	elseif not srv.port then
+		module:log("warn", "Service missing recommended 'port' field: %q", item);
+	end
+	if type(item.username) == "string" then
+		srv.username = item.username;
+	end
+	if type(item.password) == "string" then
+		srv.password = item.password;
+		srv.restricted = true;
+	end
+	if item.restricted == true then
+		srv.restricted = true;
+	end
+	if type(item.expires) == "number" then
+		srv.expires = item.expires;
+	elseif type(item.ttl) == "number" then
+		srv.expires = os.time() + item.ttl;
+	end
+	if (item.secret == true and default_secret) or type(item.secret) == "string" then
+		local secret_cb = item.credentials_cb or algorithms[item.algorithm] or algorithms[srv.type];
+		local secret = item.secret;
+		if secret == true then
+			secret = default_secret;
+		end
+		if secret_cb then
+			secret_cb(srv, item, secret);
+			srv.restricted = true;
+		end
+	end
+	return srv;
+end
+
+function module.load()
+	-- Trigger errors on startup
+	local extras = module:get_host_items("external_service");
+	local services = ( configured_services + extras ) / prepare;
+	if #services == 0 then
+		module:set_status("warn", "No services configured or all had errors");
+	end
+end
+
+module:handle_items("external_service", function(added)
+	if prepare(added.item) then
+		module:set_status("core", "OK");
+	end
+end, module.load);
+
+-- Ensure only valid items are added in events
+local services_mt = {
+	__index = getmetatable(array()).__index;
+	__newindex = function (self, i, v)
+		rawset(self, i, assert(prepare(v), "Invalid service entry added"));
+	end;
+}
+
+function get_services()
+	local extras = module:get_host_items("external_service");
+	local services = ( configured_services + extras ) / prepare;
+
+	setmetatable(services, services_mt);
+
+	return services;
+end
+
+function services_xml(services, name, namespace)
+	local reply = st.stanza(name or "services", { xmlns = namespace or "urn:xmpp:extdisco:2" });
+
+	for _, srv in ipairs(services) do
+		reply:tag("service", {
+				type = srv.type;
+				transport = srv.transport;
+				host = srv.host;
+				port = srv.port and string.format("%d", srv.port) or nil;
+				username = srv.username;
+				password = srv.password;
+				expires = srv.expires and dt.datetime(srv.expires) or nil;
+				restricted = srv.restricted and "1" or nil;
+			}):up();
+	end
+
+	return reply;
+end
+
+local function handle_services(event)
+	local origin, stanza = event.origin, event.stanza;
+	local action = stanza.tags[1];
+
+	local user_bare = jid.bare(stanza.attr.from);
+	local user_host = jid.host(user_bare);
+	if not ((access:empty() and origin.type == "c2s") or access:contains(user_bare) or access:contains(user_host)) then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+
+	local services = get_services();
+
+	local requested_type = action.attr.type;
+	if requested_type then
+		services:filter(function(item)
+			return item.type == requested_type;
+		end);
+	end
+
+	module:fire_event("external_service/services", {
+			origin = origin;
+			stanza = stanza;
+			requested_type = requested_type;
+			services = services;
+		});
+
+	local reply = st.reply(stanza):add_child(services_xml(services, action.name, action.attr.xmlns));
+
+	origin.send(reply);
+	return true;
+end
+
+local function handle_credentials(event)
+	local origin, stanza = event.origin, event.stanza;
+	local action = stanza.tags[1];
+
+	if origin.type ~= "c2s" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+
+	local services = get_services();
+	services:filter(function (item)
+		return item.restricted;
+	end)
+
+	local requested_credentials = set.new();
+	for service in action:childtags("service") do
+		if not service.attr.type or not service.attr.host then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "The 'port' and 'type' attributes are required."));
+			return true;
+		end
+
+		requested_credentials:add(string.format("%s:%s:%d", service.attr.type, service.attr.host,
+			tonumber(service.attr.port) or 0));
+	end
+
+	module:fire_event("external_service/credentials", {
+			origin = origin;
+			stanza = stanza;
+			requested_credentials = requested_credentials;
+			services = services;
+		});
+
+	services:filter(function (srv)
+		local port_key = string.format("%s:%s:%d", srv.type, srv.host, srv.port or 0);
+		local portless_key = string.format("%s:%s:%d", srv.type, srv.host, 0);
+		return requested_credentials:contains(port_key) or requested_credentials:contains(portless_key);
+	end);
+
+	local reply = st.reply(stanza):add_child(services_xml(services, action.name, action.attr.xmlns));
+
+	origin.send(reply);
+	return true;
+end
+
+-- XEP-0215 v0.7
+module:add_feature("urn:xmpp:extdisco:2");
+module:hook("iq-get/host/urn:xmpp:extdisco:2:services", handle_services);
+module:hook("iq-get/host/urn:xmpp:extdisco:2:credentials", handle_credentials);
+
+-- COMPAT XEP-0215 v0.6
+-- Those still on the old version gets to deal with undefined attributes until they upgrade.
+module:add_feature("urn:xmpp:extdisco:1");
+module:hook("iq-get/host/urn:xmpp:extdisco:1:services", handle_services);
+module:hook("iq-get/host/urn:xmpp:extdisco:1:credentials", handle_credentials);
--- a/plugins/mod_groups.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_groups.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -25,7 +25,7 @@
 	local function import_jids_to_roster(group_name)
 		for jid in pairs(groups[group_name]) do
 			-- Add them to roster
-			--module:log("debug", "processing jid %s in group %s", tostring(jid), tostring(group_name));
+			--module:log("debug", "processing jid %s in group %s", jid, group_name);
 			if jid ~= bare_jid then
 				if not roster[jid] then roster[jid] = {}; end
 				roster[jid].subscription = "both";
@@ -99,7 +99,7 @@
 				end
 				members[false][#members[false]+1] = curr_group; -- Is a public group
 			end
-			module:log("debug", "New group: %s", tostring(curr_group));
+			module:log("debug", "New group: %s", curr_group);
 			groups[curr_group] = groups[curr_group] or {};
 		else
 			-- Add JID
@@ -108,7 +108,7 @@
 			local jid;
 			jid = jid_prep(entryjid:match("%S+"));
 			if jid then
-				module:log("debug", "New member of %s: %s", tostring(curr_group), tostring(jid));
+				module:log("debug", "New member of %s: %s", curr_group, jid);
 				groups[curr_group][jid] = name or false;
 				members[jid] = members[jid] or {};
 				members[jid][#members[jid]+1] = curr_group;
--- a/plugins/mod_http.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_http.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,13 +7,21 @@
 --
 
 module:set_global();
-module:depends("http_errors");
+pcall(function ()
+	module:depends("http_errors");
+end);
 
 local portmanager = require "core.portmanager";
 local moduleapi = require "core.moduleapi";
 local url_parse = require "socket.url".parse;
 local url_build = require "socket.url".build;
 local normalize_path = require "util.http".normalize_path;
+local set = require "util.set";
+
+local ip_util = require "util.ip";
+local new_ip = ip_util.new_ip;
+local match_ip = ip_util.match;
+local parse_cidr = ip_util.parse_cidr;
 
 local server = require "net.http.server";
 
@@ -22,6 +30,15 @@
 server.set_option("body_size_limit", module:get_option_number("http_max_content_size"));
 server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size"));
 
+-- CORS settings
+local cors_overrides = module:get_option("http_cors_override", {});
+local opt_methods = module:get_option_set("access_control_allow_methods", { "GET", "OPTIONS" });
+local opt_headers = module:get_option_set("access_control_allow_headers", { "Content-Type" });
+local opt_origins = module:get_option_set("access_control_allow_origins");
+local opt_credentials = module:get_option_boolean("access_control_allow_credentials", false);
+local opt_max_age = module:get_option_number("access_control_max_age", 2 * 60 * 60);
+local opt_default_cors = module:get_option_boolean("http_default_cors_enabled", true);
+
 local function get_http_event(host, app_path, key)
 	local method, path = key:match("^(%S+)%s+(.+)$");
 	if not method then -- No path specified, default to "" (base path)
@@ -60,29 +77,54 @@
 -- Helper to deduce a module's external URL
 function moduleapi.http_url(module, app_name, default_path)
 	app_name = app_name or (module.name:gsub("^http_", ""));
-	local external_url = url_parse(module:get_option_string("http_external_url")) or {};
-	if external_url.scheme and external_url.port == nil then
-		external_url.port = ports_by_scheme[external_url.scheme];
+
+	local external_url = url_parse(module:get_option_string("http_external_url"));
+	if external_url then
+		local url = {
+			scheme = external_url.scheme;
+			host = external_url.host;
+			port = tonumber(external_url.port) or ports_by_scheme[external_url.scheme];
+			path = normalize_path(external_url.path or "/", true)
+				.. (get_base_path(module, app_name, default_path or "/" .. app_name):sub(2));
+		}
+		if ports_by_scheme[url.scheme] == url.port then url.port = nil end
+		return url_build(url);
 	end
+
 	local services = portmanager.get_active_services();
 	local http_services = services:get("https") or services:get("http") or {};
 	for interface, ports in pairs(http_services) do -- luacheck: ignore 213/interface
 		for port, service in pairs(ports) do -- luacheck: ignore 512
 			local url = {
-				scheme = (external_url.scheme or service[1].service.name);
-				host = (external_url.host or module:get_option_string("http_host", module.host));
-				port = tonumber(external_url.port) or port or 80;
-				path = normalize_path(external_url.path or "/", true)..
-					(get_base_path(module, app_name, default_path or "/"..app_name):sub(2));
+				scheme = service[1].service.name;
+				host = module:get_option_string("http_host", module.global
+					and module:get_option_string("http_default_host", interface) or module.host);
+				port = port;
+				path = get_base_path(module, app_name, default_path or "/" .. app_name);
 			}
 			if ports_by_scheme[url.scheme] == url.port then url.port = nil end
 			return url_build(url);
 		end
 	end
-	module:log("warn", "No http ports enabled, can't generate an external URL");
+	if prosody.process_type == "prosody" then
+		module:log("warn", "No http ports enabled, can't generate an external URL");
+	end
 	return "http://disabled.invalid/";
 end
 
+local function apply_cors_headers(response, methods, headers, max_age, allow_credentials, allowed_origins, origin)
+	if allowed_origins and not allowed_origins[origin] then
+		return;
+	end
+	response.headers.access_control_allow_methods = tostring(methods);
+	response.headers.access_control_allow_headers = tostring(headers);
+	response.headers.access_control_max_age = tostring(max_age)
+	response.headers.access_control_allow_origin = origin or "*";
+	if allow_credentials then
+		response.headers.access_control_allow_credentials = "true";
+	end
+end
+
 function module.add_host(module)
 	local host = module.host;
 	if host ~= "*" then
@@ -101,9 +143,70 @@
 		end
 		apps[app_name] = apps[app_name] or {};
 		local app_handlers = apps[app_name];
-		for key, handler in pairs(event.item.route or {}) do
+
+		local app_methods = opt_methods;
+		local app_headers = opt_headers;
+		local app_credentials = opt_credentials;
+		local app_origins;
+		if opt_origins and not (opt_origins:empty() or opt_origins:contains("*")) then
+			opt_origins = opt_origins._items;
+		end
+
+		local function cors_handler(event_data)
+			local request, response = event_data.request, event_data.response;
+			apply_cors_headers(response, app_methods, app_headers, opt_max_age, app_credentials, app_origins, request.headers.origin);
+		end
+
+		local function options_handler(event_data)
+			cors_handler(event_data);
+			return "";
+		end
+
+		local cors = cors_overrides[app_name] or event.item.cors;
+		if cors then
+			if cors.enabled == true then
+				if cors.credentials ~= nil then
+					app_credentials = cors.credentials;
+				end
+				if cors.headers then
+					for header, enable in pairs(cors.headers) do
+						if enable and not app_headers:contains(header) then
+							app_headers = app_headers + set.new { header };
+						elseif not enable and app_headers:contains(header) then
+							app_headers = app_headers - set.new { header };
+						end
+					end
+				end
+				if cors.origins then
+					if cors.origins == "*" or cors.origins[1] == "*" then
+						app_origins = nil;
+					else
+						app_origins = set.new(cors.origins)._items;
+					end
+				end
+			elseif cors.enabled == false then
+				cors = nil;
+			end
+		else
+			cors = opt_default_cors;
+		end
+
+		local streaming = event.item.streaming_uploads;
+
+		if not event.item.route then
+			-- TODO: Link to docs
+			module:log("error", "HTTP app %q provides no 'route', add one to handle HTTP requests", app_name);
+			return;
+		end
+
+		for key, handler in pairs(event.item.route) do
 			local event_name = get_http_event(host, app_path, key);
 			if event_name then
+				local method = event_name:match("^%S+");
+				if not app_methods:contains(method) then
+					app_methods = app_methods + set.new{ method };
+				end
+				local options_event_name = event_name:gsub("^%S+", "OPTIONS");
 				if type(handler) ~= "function" then
 					local data = handler;
 					handler = function () return data; end
@@ -118,9 +221,26 @@
 				elseif event_name:sub(-1, -1) == "/" then
 					module:hook_object_event(server, event_name:sub(1, -2), redir_handler, -1);
 				end
+				if not streaming then
+					-- COMPAT Modules not compatible with streaming uploads behave as before.
+					local _handler = handler;
+					function handler(event) -- luacheck: ignore 432/event
+						if event.request.body ~= false then
+							return _handler(event);
+						end
+					end
+				end
 				if not app_handlers[event_name] then
-					app_handlers[event_name] = handler;
+					app_handlers[event_name] = {
+						main = handler;
+						cors = cors and cors_handler;
+						options = cors and options_handler;
+					};
 					module:hook_object_event(server, event_name, handler);
+					if cors then
+						module:hook_object_event(server, event_name, cors_handler, 1);
+						module:hook_object_event(server, options_event_name, options_handler, -1);
+					end
 				else
 					module:log("warn", "App %s added handler twice for '%s', ignoring", app_name, event_name);
 				end
@@ -130,17 +250,27 @@
 		end
 		local services = portmanager.get_active_services();
 		if services:get("https") or services:get("http") then
-			module:log("debug", "Serving '%s' at %s", app_name, module:http_url(app_name, app_path));
-		else
-			module:log("warn", "Not listening on any ports, '%s' will be unreachable", app_name);
+			module:log("info", "Serving '%s' at %s", app_name, module:http_url(app_name, app_path));
+		elseif prosody.process_type == "prosody" then
+			module:log("error", "Not listening on any ports, '%s' will be unreachable", app_name);
 		end
 	end
 
 	local function http_app_removed(event)
 		local app_handlers = apps[event.item.name];
 		apps[event.item.name] = nil;
-		for event_name, handler in pairs(app_handlers) do
-			module:unhook_object_event(server, event_name, handler);
+		for event_name, handlers in pairs(app_handlers) do
+			module:unhook_object_event(server, event_name, handlers.main);
+			module:unhook_object_event(server, event_name, handlers.cors);
+
+			if event_name:sub(-2, -1) == "/*" then
+				module:unhook_object_event(server, event_name:sub(1, -3), redir_handler, -1);
+			elseif event_name:sub(-1, -1) == "/" then
+				module:unhook_object_event(server, event_name:sub(1, -2), redir_handler, -1);
+			end
+
+			local options_event_name = event_name:gsub("^%S+", "OPTIONS");
+			module:unhook_object_event(server, options_event_name, handlers.options);
 		end
 	end
 
@@ -158,25 +288,50 @@
 
 local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items;
 
-local function get_ip_from_request(request)
-	local ip = request.conn:ip();
+local function is_trusted_proxy(ip)
+	if trusted_proxies[ip] then
+		return true;
+	end
+	local parsed_ip = new_ip(ip)
+	for trusted_proxy in trusted_proxies do
+		if match_ip(parsed_ip, parse_cidr(trusted_proxy)) then
+			return true;
+		end
+	end
+	return false
+end
+
+local function get_forwarded_connection_info(request) --> ip:string, secure:boolean
+	local ip = request.ip;
+	local secure = request.secure; -- set by net.http.server
 	local forwarded_for = request.headers.x_forwarded_for;
-	if forwarded_for and trusted_proxies[ip] then
+	if forwarded_for then
+		-- luacheck: ignore 631
+		-- This logic looks weird at first, but it makes sense.
+		-- The for loop will take the last non-trusted-proxy IP from `forwarded_for`.
+		-- We append the original request IP to the header. Then, since the last IP wins, there are two cases:
+		-- Case a) The original request IP is *not* in trusted proxies, in which case the X-Forwarded-For header will, effectively, be ineffective; the original request IP will win because it overrides any other IP in the header.
+		-- Case b) The original request IP is in trusted proxies. In that case, the if branch in the for loop will skip the last IP, causing it to be ignored. The second-to-last IP will be taken instead.
+		-- Case c) If the second-to-last IP is also a trusted proxy, it will also be ignored, iteratively, up to the last IP which isn’t in trusted proxies.
+		-- Case d) If all IPs are in trusted proxies, something went obviously wrong and the logic never overwrites `ip`, leaving it at the original request IP.
 		forwarded_for = forwarded_for..", "..ip;
 		for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do
-			if not trusted_proxies[forwarded_ip] then
+			if not is_trusted_proxy(forwarded_ip) then
 				ip = forwarded_ip;
 			end
 		end
 	end
-	return ip;
+
+	secure = secure or request.headers.x_forwarded_proto == "https";
+
+	return ip, secure;
 end
 
 module:wrap_object_event(server._events, false, function (handlers, event_name, event_data)
 	local request = event_data.request;
-	if request then
+	if request and is_trusted_proxy(request.ip) then
 		-- Not included in eg http-error events
-		request.ip = get_ip_from_request(request);
+		request.ip, request.secure = get_forwarded_connection_info(request);
 	end
 	return handlers(event_name, event_data);
 end);
@@ -184,6 +339,7 @@
 module:provides("net", {
 	name = "http";
 	listener = server.listener;
+	private = true;
 	default_port = 5280;
 	multiplex = {
 		pattern = "^[A-Z]";
@@ -195,10 +351,8 @@
 	listener = server.listener;
 	default_port = 5281;
 	encryption = "ssl";
-	ssl_config = {
-		verify = "none";
-	};
 	multiplex = {
+		protocol = "http/1.1";
 		pattern = "^[A-Z]";
 	};
 });
--- a/plugins/mod_http_errors.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_http_errors.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -15,6 +15,15 @@
 		"Where did you put it?", "It's behind you.", "Keep looking." };
 	[500] = { "% Check your error log for more info.";
 		"Gremlins.", "It broke.", "Don't look at me." };
+	["/"] = {
+		"A study in simplicity.";
+		"Better catch it!";
+		"Don't just stand there, go after it!";
+		"Well, say something, before it runs too far!";
+		"Welcome to the world of XMPP!";
+		"You can do anything in XMPP!"; -- "The only limit is XML.";
+		"You can do anything with Prosody!"; -- the only limit is memory?
+	};
 };
 
 local messages = setmetatable(module:get_option("http_errors_messages", {}), { __index = default_messages });
@@ -26,28 +35,22 @@
 <meta charset="utf-8">
 <title>{title}</title>
 <style>
-body{
-	margin-top:14%;
-	text-align:center;
-	background-color:#F8F8F8;
-	font-family:sans-serif;
-}
-h1{
-	font-size:xx-large;
-}
-p{
-	font-size:x-large;
-}
-p+p {
-	font-size:large;
-	font-family:courier;
+body{margin-top:14%;text-align:center;background-color:#f8f8f8;font-family:sans-serif}
+h1{font-size:xx-large}
+p{font-size:x-large}
+p.warning>span{font-size:large;background-color:yellow}
+p.extra{font-size:large;font-family:courier}
+@media(prefers-color-scheme:dark){
+body{background-color:#161616;color:#eee}
+p.warning>span{background-color:inherit;color:yellow}
 }
 </style>
 </head>
 <body>
-<h1>{title}</h1>
+<h1>{icon?{icon_raw!?}} {title}</h1>
 <p>{message}</p>
-<p>{extra?}</p>
+{warning&<p class="warning"><span>&#9888; {warning?} &#9888;</span></p>}
+{extra&<p class="extra">{extra?}</p>}
 </body>
 </html>
 ]];
@@ -66,9 +69,43 @@
 	end
 end
 
+-- Main error page handler
 module:hook_object_event(server, "http-error", function (event)
 	if event.response then
 		event.response.headers.content_type = "text/html; charset=utf-8";
 	end
-	return get_page(event.code, (show_private and event.private_message) or event.message);
+	return get_page(event.code, (show_private and event.private_message) or event.message or (event.error and event.error.text));
 end);
+
+-- Way to use the template for other things so to give a consistent appearance
+module:hook("http-message", function (event)
+	if event.response then
+		event.response.headers.content_type = "text/html; charset=utf-8";
+	end
+	return render(html, event);
+end, -1);
+
+local icon = [[
+<svg xmlns="http://www.w3.org/2000/svg" height="0.7em" viewBox="0 0 480 480" width="0.7em">
+<rect fill="#6197df" height="220" rx="60" ry="60" width="220" x="10" y="10"></rect>
+<rect fill="#f29b00" height="220" rx="60" ry="60" width="220" x="10" y="240"></rect>
+<rect fill="#f29b00" height="220" rx="60" ry="60" width="220" x="240" y="10"></rect>
+<rect fill="#6197df" height="220" rx="60" ry="60" width="220" x="240" y="240"></rect>
+</svg>
+]];
+
+-- Something nicer shown instead of 404 at the root path, if nothing else handles this path
+module:hook_object_event(server, "http-error", function (event)
+	local request, response = event.request, event.response;
+	if request and response and request.path == "/" and response.status_code == 404 then
+		response.status_code = 200;
+		response.headers.content_type = "text/html; charset=utf-8";
+		local message = messages["/"];
+		return render(html, {
+				icon_raw = icon,
+				title = "Prosody is running!";
+				message = message[math.random(#message)];
+			});
+	end
+end, 1);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_http_file_share.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,601 @@
+-- Prosody IM
+-- Copyright (C) 2021 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- XEP-0363: HTTP File Upload
+-- Again, from the top!
+
+local t_insert = table.insert;
+local jid = require "util.jid";
+local st = require "util.stanza";
+local url = require "socket.url";
+local dm = require "core.storagemanager".olddm;
+local jwt = require "util.jwt";
+local errors = require "util.error";
+local dataform = require "util.dataforms".new;
+local urlencode = require "util.http".urlencode;
+local dt = require "util.datetime";
+local hi = require "util.human.units";
+local cache = require "util.cache";
+local lfs = require "lfs";
+
+local unknown = math.abs(0/0);
+local unlimited = math.huge;
+
+local namespace = "urn:xmpp:http:upload:0";
+
+module:depends("disco");
+
+module:add_identity("store", "file", module:get_option_string("name", "HTTP File Upload"));
+module:add_feature(namespace);
+
+local uploads = module:open_store("uploads", "archive");
+local persist_stats = module:open_store("upload_stats", "map");
+-- id, <request>, time, owner
+
+local secret = module:get_option_string(module.name.."_secret", require"util.id".long());
+local external_base_url = module:get_option_string(module.name .. "_base_url");
+local file_size_limit = module:get_option_number(module.name .. "_size_limit", 10 * 1024 * 1024); -- 10 MB
+local file_types = module:get_option_set(module.name .. "_allowed_file_types", {});
+local safe_types = module:get_option_set(module.name .. "_safe_file_types", {"image/*","video/*","audio/*","text/plain"});
+local expiry = module:get_option_number(module.name .. "_expires_after", 7 * 86400);
+local daily_quota = module:get_option_number(module.name .. "_daily_quota", file_size_limit*10); -- 100 MB / day
+local total_storage_limit = module:get_option_number(module.name.."_global_quota", unlimited);
+
+local access = module:get_option_set(module.name .. "_access", {});
+
+if not external_base_url then
+	module:depends("http");
+end
+
+module:add_extension(dataform {
+	{ name = "FORM_TYPE", type = "hidden", value = namespace },
+	{ name = "max-file-size", type = "text-single", datatype = "xs:integer" },
+}:form({ ["max-file-size"] = file_size_limit }, "result"));
+
+local upload_errors = errors.init(module.name, namespace, {
+	access = { type = "auth"; condition = "forbidden" };
+	filename = { type = "modify"; condition = "bad-request"; text = "Invalid filename" };
+	filetype = { type = "modify"; condition = "not-acceptable"; text = "File type not allowed" };
+	filesize = { type = "modify"; condition = "not-acceptable"; text = "File too large";
+		extra = {tag = st.stanza("file-too-large", {xmlns = namespace}):tag("max-file-size"):text(tostring(file_size_limit)) };
+	};
+	filesizefmt = { type = "modify"; condition = "bad-request"; text = "File size must be positive integer"; };
+	quota = { type = "wait"; condition = "resource-constraint"; text = "Daily quota reached"; };
+	outofdisk = { type = "wait"; condition = "resource-constraint"; text = "Server global storage quota reached" };
+});
+
+local upload_cache = cache.new(1024);
+local quota_cache = cache.new(1024);
+
+local total_storage_usage = unknown;
+
+local measure_upload_cache_size = module:measure("upload_cache", "amount");
+local measure_quota_cache_size = module:measure("quota_cache", "amount");
+local measure_total_storage_usage = module:measure("total_storage", "amount", { unit = "bytes" });
+
+do
+	local total, err = persist_stats:get(nil, "total");
+	if not err then
+		total_storage_usage = tonumber(total) or 0;
+	end
+end
+
+module:hook_global("stats-update", function ()
+	measure_upload_cache_size(upload_cache:count());
+	measure_quota_cache_size(quota_cache:count());
+	measure_total_storage_usage(total_storage_usage);
+end);
+
+local buckets = {};
+for n = 10, 40, 2 do
+	local exp = math.floor(2 ^ n);
+	table.insert(buckets, exp);
+	if exp >= file_size_limit then break end
+end
+local measure_uploads = module:measure("upload", "sizes", {buckets = buckets});
+
+-- Convenience wrapper for logging file sizes
+local function B(bytes)
+	if bytes ~= bytes then
+		return "unknown"
+	elseif bytes == unlimited then
+		return "unlimited";
+	end
+	return hi.format(bytes, "B", "b");
+end
+
+local function get_filename(slot, create)
+	return dm.getpath(slot, module.host, module.name, "bin", create)
+end
+
+function get_daily_quota(uploader)
+	local now = os.time();
+	local max_age = now - 86400;
+	local cached = quota_cache:get(uploader);
+	if cached and cached.time > max_age then
+		return cached.size;
+	end
+	local iter, err = uploads:find(nil, {with = uploader; start = max_age });
+	if not iter then return iter, err; end
+	local total_bytes = 0;
+	local oldest_upload = now;
+	for _, slot, when in iter do
+		local size = tonumber(slot.attr.size);
+		if size then total_bytes = total_bytes + size; end
+		if when < oldest_upload then oldest_upload = when; end
+	end
+	-- If there were no uploads then we end up caching [now, 0], which is fine
+	-- since we increase the size on new uploads
+	quota_cache:set(uploader, { time = oldest_upload, size = total_bytes });
+	return total_bytes;
+end
+
+function may_upload(uploader, filename, filesize, filetype) -- > boolean, error
+	local uploader_host = jid.host(uploader);
+	if not ((access:empty() and prosody.hosts[uploader_host]) or access:contains(uploader) or access:contains(uploader_host)) then
+		return false, upload_errors.new("access");
+	end
+
+	if not filename or filename:find"/" then
+		-- On Linux, only '/' and '\0' are invalid in filenames and NUL can't be in XML
+		return false, upload_errors.new("filename");
+	end
+
+	if not filesize or filesize < 0 or filesize % 1 ~= 0 then
+		return false, upload_errors.new("filesizefmt");
+	end
+	if filesize > file_size_limit then
+		return false, upload_errors.new("filesize");
+	end
+
+	if total_storage_usage + filesize > total_storage_limit then
+		module:log("warn", "Global storage quota reached, at %s / %s!", B(total_storage_usage), B(total_storage_limit));
+		return false, upload_errors.new("outofdisk");
+	end
+
+	local uploader_quota = get_daily_quota(uploader);
+	if uploader_quota + filesize > daily_quota then
+		return false, upload_errors.new("quota");
+	end
+
+	if not ( file_types:empty() or file_types:contains(filetype) or file_types:contains(filetype:gsub("/.*", "/*")) ) then
+		return false, upload_errors.new("filetype");
+	end
+
+	return true;
+end
+
+function get_authz(slot, uploader, filename, filesize, filetype)
+local now = os.time();
+	return jwt.sign(secret, {
+		-- token properties
+		sub = uploader;
+		iat = now;
+		exp = now+300;
+
+		-- slot properties
+		slot = slot;
+		expires = expiry >= 0 and (now+expiry) or nil;
+		-- file properties
+		filename = filename;
+		filesize = filesize;
+		filetype = filetype;
+	});
+end
+
+function get_url(slot, filename)
+	local base_url = external_base_url or module:http_url();
+	local slot_url = url.parse(base_url);
+	slot_url.path = url.parse_path(slot_url.path or "/");
+	t_insert(slot_url.path, slot);
+	if filename then
+		t_insert(slot_url.path, filename);
+		slot_url.path.is_directory = false;
+	else
+		slot_url.path.is_directory = true;
+	end
+	slot_url.path = url.build_path(slot_url.path);
+	return url.build(slot_url);
+end
+
+function handle_slot_request(event)
+	local stanza, origin = event.stanza, event.origin;
+
+	local request = st.clone(stanza.tags[1], true);
+	local filename = request.attr.filename;
+	local filesize = tonumber(request.attr.size);
+	local filetype = request.attr["content-type"] or "application/octet-stream";
+	local uploader = jid.bare(stanza.attr.from);
+
+	local may, why_not = may_upload(uploader, filename, filesize, filetype);
+	if not may then
+		origin.send(st.error_reply(stanza, why_not));
+		return true;
+	end
+
+	module:log("info", "Issuing upload slot to %s for %s", uploader, B(filesize));
+	local slot, storage_err = errors.coerce(uploads:append(nil, nil, request, os.time(), uploader))
+	if not slot then
+		origin.send(st.error_reply(stanza, storage_err));
+		return true;
+	end
+
+	total_storage_usage = total_storage_usage + filesize;
+	module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+
+	local cached_quota = quota_cache:get(uploader);
+	if cached_quota and cached_quota.time > os.time()-86400 then
+		cached_quota.size = cached_quota.size + filesize;
+		quota_cache:set(uploader, cached_quota);
+	end
+
+	local authz = get_authz(slot, uploader, filename, filesize, filetype);
+	local slot_url = get_url(slot, filename);
+	local upload_url = slot_url;
+
+	local reply = st.reply(stanza)
+		:tag("slot", { xmlns = namespace })
+			:tag("get", { url = slot_url }):up()
+			:tag("put", { url = upload_url })
+				:text_tag("header", "Bearer "..authz, {name="Authorization"})
+		:reset();
+
+	origin.send(reply);
+	return true;
+end
+
+function handle_upload(event, path) -- PUT /upload/:slot
+	local request = event.request;
+	local authz = request.headers.authorization;
+	if authz then
+		authz = authz:match("^Bearer (.*)")
+	end
+	if not authz then
+		module:log("debug", "Missing or malformed Authorization header");
+		event.response.headers.www_authenticate = "Bearer";
+		return 401;
+	end
+	local authed, upload_info = jwt.verify(secret, authz);
+	if not (authed and type(upload_info) == "table" and type(upload_info.exp) == "number") then
+		module:log("debug", "Unauthorized or invalid token: %s, %q", authed, upload_info);
+		return 401;
+	end
+	if not request.body_sink and upload_info.exp < os.time() then
+		module:log("debug", "Authorization token expired on %s", dt.datetime(upload_info.exp));
+		return 410;
+	end
+	if not path or upload_info.slot ~= path:match("^[^/]+") then
+		module:log("debug", "Invalid upload slot: %q, path: %q", upload_info.slot, path);
+		return 400;
+	end
+	if request.headers.content_length and tonumber(request.headers.content_length) ~= upload_info.filesize then
+		return 413;
+		-- Note: We don't know the size if the upload is streamed in chunked encoding,
+		-- so we also check the final file size on completion.
+	end
+
+	local filename = get_filename(upload_info.slot, true);
+
+	do
+		-- check if upload has been completed already
+		-- we want to allow retry of a failed upload attempt, but not after it's been completed
+		local f = io.open(filename, "r");
+		if f then
+			f:close();
+			return 409;
+		end
+	end
+
+	if not request.body_sink then
+		module:log("debug", "Preparing to receive upload into %q, expecting %s", filename, B(upload_info.filesize));
+		local fh, err = io.open(filename.."~", "w");
+		if not fh then
+			module:log("error", "Could not open file for writing: %s", err);
+			return 500;
+		end
+		function event.response:on_destroy() -- luacheck: ignore 212/self
+			-- Clean up incomplete upload
+			if io.type(fh) == "file" then -- still open
+				fh:close();
+				os.remove(filename.."~");
+			end
+		end
+		request.body_sink = fh;
+		if request.body == false then
+			if request.headers.expect == "100-continue" then
+				request.conn:write("HTTP/1.1 100 Continue\r\n\r\n");
+			end
+			return true;
+		end
+	end
+
+	if request.body then
+		module:log("debug", "Complete upload available, %s", B(#request.body));
+		-- Small enough to have been uploaded already
+		local written, err = errors.coerce(request.body_sink:write(request.body));
+		if not written then
+			return err;
+		end
+		request.body = nil;
+	end
+
+	if request.body_sink then
+		local final_size = request.body_sink:seek();
+		local uploaded, err = errors.coerce(request.body_sink:close());
+		if final_size ~= upload_info.filesize then
+			-- Could be too short as well, but we say the same thing
+			uploaded, err = false, 413;
+		end
+		if uploaded then
+			module:log("debug", "Upload of %q completed, %s", filename, B(final_size));
+			assert(os.rename(filename.."~", filename));
+			measure_uploads(final_size);
+
+			upload_cache:set(upload_info.slot, {
+					name = upload_info.filename;
+					size = tostring(upload_info.filesize);
+					type = upload_info.filetype;
+					time = os.time();
+				});
+			return 201;
+		else
+			assert(os.remove(filename.."~"));
+			return err;
+		end
+	end
+
+end
+
+local download_cache_hit = module:measure("download_cache_hit", "rate");
+local download_cache_miss = module:measure("download_cache_miss", "rate");
+
+function handle_download(event, path) -- GET /uploads/:slot+filename
+	local request, response = event.request, event.response;
+	local slot_id = path:match("^[^/]+");
+	local basename, filetime, filetype, filesize;
+	local cached = upload_cache:get(slot_id);
+	if cached then
+		module:log("debug", "Cache hit");
+		download_cache_hit();
+		basename = cached.name;
+		filesize = cached.size;
+		filetype = cached.type;
+		filetime = cached.time;
+		upload_cache:set(slot_id, cached);
+		-- TODO cache negative hits?
+	else
+		module:log("debug", "Cache miss");
+		download_cache_miss();
+		local slot, when = errors.coerce(uploads:get(nil, slot_id));
+		if not slot then
+			module:log("debug", "uploads:get(%q) --> not-found, %s", slot_id, when);
+		else
+			module:log("debug", "uploads:get(%q) --> %s, %d", slot_id, slot, when);
+			basename = slot.attr.filename;
+			filesize = slot.attr.size;
+			filetype = slot.attr["content-type"];
+			filetime = when;
+			upload_cache:set(slot_id, {
+					name = basename;
+					size = slot.attr.size;
+					type = filetype;
+					time = when;
+				});
+		end
+	end
+	if not basename then
+		return 404;
+	end
+	local last_modified = os.date('!%a, %d %b %Y %H:%M:%S GMT', filetime);
+	if request.headers.if_modified_since == last_modified then
+		return 304;
+	end
+	local filename = get_filename(slot_id);
+	local handle, ferr = io.open(filename);
+	if not handle then
+		module:log("error", "Could not open file for reading: %s", ferr);
+		-- This can be because the upload slot wasn't used, or the file disappeared
+		-- somehow, or permission issues.
+		return 410;
+	end
+
+	local request_range = request.headers.range;
+	local response_range;
+	if request_range then
+		local range_start, range_end = request_range:match("^bytes=(%d+)%-(%d*)$")
+		-- Only support resumption, ie ranges from somewhere in the middle until the end of the file.
+		if (range_start and range_start ~= "0") and (range_end == "" or range_end == filesize) then
+			local pos, size = tonumber(range_start), tonumber(filesize);
+			local new_pos = pos < size and handle:seek("set", pos);
+			if new_pos and new_pos < size then
+				response_range = "bytes "..range_start.."-"..filesize.."/"..filesize;
+				filesize = string.format("%d", size-pos);
+			else
+				handle:close();
+				return 416;
+			end
+		end
+	end
+
+
+	if not filetype then
+		filetype = "application/octet-stream";
+	end
+	local disposition = "attachment";
+	if safe_types:contains(filetype) or safe_types:contains(filetype:gsub("/.*", "/*")) then
+		disposition = "inline";
+	end
+
+	response.headers.last_modified = last_modified;
+	response.headers.content_length = filesize;
+	response.headers.content_type = filetype;
+	response.headers.content_disposition = string.format("%s; filename*=UTF-8''%s", disposition, urlencode(basename));
+
+	if response_range then
+		response.status_code = 206;
+		response.headers.content_range = response_range;
+	end
+	response.headers.accept_ranges = "bytes";
+
+	response.headers.cache_control = "max-age=31556952, immutable";
+	response.headers.content_security_policy =  "default-src 'none'; frame-ancestors 'none';"
+	response.headers.strict_transport_security = "max-age=31556952";
+	response.headers.x_content_type_options = "nosniff";
+	response.headers.x_frame_options = "DENY"; -- COMPAT IE missing support for CSP frame-ancestors
+	response.headers.x_xss_protection = "1; mode=block";
+
+	return response:send_file(handle);
+end
+
+if expiry >= 0 and not external_base_url then
+	-- TODO HTTP DELETE to the external endpoint?
+	local array = require "util.array";
+	local async = require "util.async";
+	local ENOENT = require "util.pposix".ENOENT;
+
+	local function sleep(t)
+		local wait, done = async.waiter();
+		module:add_timer(t, done)
+		wait();
+	end
+
+	local prune_start = module:measure("prune", "times");
+
+	module:daily("Remove expired files", function(_, current_time)
+		local prune_done = prune_start();
+		local boundary_time = (current_time or os.time()) - expiry;
+		local iter, total = assert(uploads:find(nil, {["end"] = boundary_time; total = true}));
+
+		if total == 0 then
+			module:log("info", "No expired uploaded files to prune");
+			prune_done();
+			return;
+		end
+
+		module:log("info", "Pruning expired files uploaded earlier than %s", dt.datetime(boundary_time));
+		module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+
+		local obsolete_uploads = array();
+		local num_expired = 0;
+		local size_sum = 0;
+		local problem_deleting = false;
+		for slot_id, slot_info in iter do
+			num_expired = num_expired + 1;
+			upload_cache:set(slot_id, nil);
+			local filename = get_filename(slot_id);
+			local deleted, err, errno = os.remove(filename);
+			if deleted or errno == ENOENT then -- removed successfully or it was already gone
+				size_sum = size_sum + tonumber(slot_info.attr.size);
+				obsolete_uploads:push(slot_id);
+			else
+				module:log("error", "Could not prune expired file %q: %s", filename, err);
+				problem_deleting = true;
+			end
+			if num_expired % 100 == 0 then sleep(0.1); end
+		end
+
+		-- obsolete_uploads now contains slot ids for which the files have been
+		-- removed and that needs to be cleared from the database
+
+		local deletion_query = {["end"] = boundary_time};
+		if not problem_deleting then
+			module:log("info", "All (%d, %s) expired files successfully pruned", num_expired, B(size_sum));
+			-- we can delete based on time
+		else
+			module:log("warn", "%d out of %d expired files could not be pruned", num_expired-#obsolete_uploads, num_expired);
+			-- we'll need to delete only those entries where the files were
+			-- successfully removed, and then try again with the failed ones.
+			-- eventually the admin ought to notice and fix the permissions or
+			-- whatever the problem is.
+			deletion_query = {ids = obsolete_uploads};
+		end
+
+		total_storage_usage = total_storage_usage - size_sum;
+		module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+		persist_stats:set(nil, "total", total_storage_usage);
+
+		if #obsolete_uploads == 0 then
+			module:log("debug", "No metadata to remove");
+		else
+			local removed, err = uploads:delete(nil, deletion_query);
+
+			if removed == true or removed == num_expired or removed == #obsolete_uploads then
+				module:log("debug", "Expired upload metadata pruned successfully");
+			else
+				module:log("error", "Problem removing metadata for expired files: %s", err);
+			end
+		end
+
+		prune_done();
+	end);
+end
+
+local summary_start = module:measure("summary", "times");
+
+module:weekly("Calculate total storage usage", function()
+	local summary_done = summary_start();
+	local iter = assert(uploads:find(nil));
+
+	local count, sum = 0, 0;
+	for _, file in iter do
+		sum = sum + tonumber(file.attr.size);
+		count = count + 1;
+	end
+
+	module:log("info", "Uploaded files total: %s in %d files", B(sum), count);
+	if persist_stats:set(nil, "total", sum) then
+		total_storage_usage = sum;
+	else
+		total_storage_usage = unknown;
+	end
+	module:log("debug", "Total storage usage: %s / %s", B(total_storage_usage), B(total_storage_limit));
+	summary_done();
+end);
+
+-- Reachable from the console
+function check_files(query)
+	local issues = {};
+	local iter = assert(uploads:find(nil, query));
+	for slot_id, file in iter do
+		local filename = get_filename(slot_id);
+		local size, err = lfs.attributes(filename, "size");
+		if not size then
+			issues[filename] = err;
+		elseif tonumber(file.attr.size) ~= size then
+			issues[filename] = "file size mismatch";
+		end
+	end
+
+	return next(issues) == nil, issues;
+end
+
+module:hook("iq-get/host/urn:xmpp:http:upload:0:request", handle_slot_request);
+
+if not external_base_url then
+module:provides("http", {
+		streaming_uploads = true;
+		cors = {
+			enabled = true;
+			credentials = true;
+			headers = {
+				Authorization = true;
+			};
+		};
+		route = {
+			["PUT /*"] = handle_upload;
+			["GET /*"] = handle_download;
+			["GET /"] = function (event)
+				return prosody.events.fire_event("http-message", {
+						response = event.response;
+						---
+						title = "Prosody HTTP Upload endpoint";
+						message = "This is where files will be uploaded to, and served from.";
+						warning = not (event.request.secure) and "This endpoint is not considered secure!" or nil;
+					}) or "This is the Prosody HTTP Upload endpoint.";
+			end
+		}
+	});
+end
--- a/plugins/mod_http_files.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_http_files.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,14 +7,9 @@
 --
 
 module:depends("http");
-local server = require"net.http.server";
-local lfs = require "lfs";
 
-local os_date = os.date;
 local open = io.open;
-local stat = lfs.attributes;
-local build_path = require"socket.url".build_path;
-local path_sep = package.config:sub(1,1);
+local fileserver = require"net.http.files";
 
 local base_path = module:get_option_path("http_files_dir", module:get_option_path("http_path"));
 local cache_size = module:get_option_number("http_files_cache_size", 128);
@@ -38,7 +33,9 @@
 	module:shared("/*/http_files/mime").types = mime_map;
 
 	local mime_types, err = open(module:get_option_path("mime_types_file", "/etc/mime.types", "config"), "r");
-	if mime_types then
+	if not mime_types then
+		module:log("debug", "Could not open MIME database: %s", err);
+	else
 		local mime_data = mime_types:read("*a");
 		mime_types:close();
 		setmetatable(mime_map, {
@@ -51,148 +48,55 @@
 	end
 end
 
-local forbidden_chars_pattern = "[/%z]";
-if prosody.platform == "windows" then
-	forbidden_chars_pattern = "[/%z\001-\031\127\"*:<>?|]"
+local function get_calling_module()
+	local info = debug.getinfo(3, "S");
+	if not info then return "An unknown module"; end
+	return info.source:match"mod_[^/\\.]+" or info.short_src;
 end
 
-local urldecode = require "util.http".urldecode;
-function sanitize_path(path)
-	if not path then return end
-	local out = {};
-
-	local c = 0;
-	for component in path:gmatch("([^/]+)") do
-		component = urldecode(component);
-		if component:find(forbidden_chars_pattern) then
-			return nil;
-		elseif component == ".." then
-			if c <= 0 then
-				return nil;
-			end
-			out[c] = nil;
-			c = c - 1;
-		elseif component ~= "." then
-			c = c + 1;
-			out[c] = component;
-		end
-	end
-	if path:sub(-1,-1) == "/" then
-		out[c+1] = "";
-	end
-	return "/"..table.concat(out, "/");
-end
-
-local cache = require "util.cache".new(cache_size);
-
+-- COMPAT -- TODO deprecate
 function serve(opts)
 	if type(opts) ~= "table" then -- assume path string
 		opts = { path = opts };
 	end
-	-- luacheck: ignore 431
-	local base_path = opts.path;
-	local dir_indices = opts.index_files or dir_indices;
-	local directory_index = opts.directory_index;
-	local function serve_file(event, path)
-		local request, response = event.request, event.response;
-		local sanitized_path = sanitize_path(path);
-		if path and not sanitized_path then
-			return 400;
-		end
-		path = sanitized_path;
-		local orig_path = sanitize_path(request.path);
-		local full_path = base_path .. (path or ""):gsub("/", path_sep);
-		local attr = stat(full_path:match("^.*[^\\/]")); -- Strip trailing path separator because Windows
-		if not attr then
-			return 404;
-		end
-
-		local request_headers, response_headers = request.headers, response.headers;
-
-		local last_modified = os_date('!%a, %d %b %Y %H:%M:%S GMT', attr.modification);
-		response_headers.last_modified = last_modified;
-
-		local etag = ('"%x-%x-%x"'):format(attr.change or 0, attr.size or 0, attr.modification or 0);
-		response_headers.etag = etag;
-
-		local if_none_match = request_headers.if_none_match
-		local if_modified_since = request_headers.if_modified_since;
-		if etag == if_none_match
-		or (not if_none_match and last_modified == if_modified_since) then
-			return 304;
-		end
-
-		local data = cache:get(orig_path);
-		if data and data.etag == etag then
-			response_headers.content_type = data.content_type;
-			data = data.data;
-		elseif attr.mode == "directory" and path then
-			if full_path:sub(-1) ~= "/" then
-				local dir_path = { is_absolute = true, is_directory = true };
-				for dir in orig_path:gmatch("[^/]+") do dir_path[#dir_path+1]=dir; end
-				response_headers.location = build_path(dir_path);
-				return 301;
-			end
-			for i=1,#dir_indices do
-				if stat(full_path..dir_indices[i], "mode") == "file" then
-					return serve_file(event, path..dir_indices[i]);
-				end
-			end
-
-			if directory_index then
-				data = server._events.fire_event("directory-index", { path = request.path, full_path = full_path });
-			end
-			if not data then
-				return 403;
-			end
-			cache:set(orig_path, { data = data, content_type = mime_map.html; etag = etag; });
-			response_headers.content_type = mime_map.html;
-
-		else
-			local f, err = open(full_path, "rb");
-			if not f then
-				module:log("debug", "Could not open %s. Error was %s", full_path, err);
-				return 403;
-			end
-			local ext = full_path:match("%.([^./]+)$");
-			local content_type = ext and mime_map[ext];
-			response_headers.content_type = content_type;
-			if attr.size > cache_max_file_size then
-				response_headers.content_length = attr.size;
-				module:log("debug", "%d > cache_max_file_size", attr.size);
-				return response:send_file(f);
-			else
-				data = f:read("*a");
-				f:close();
-			end
-			cache:set(orig_path, { data = data; content_type = content_type; etag = etag });
-		end
-
-		return response:send(data);
+	if opts.directory_index == nil then
+		opts.directory_index = directory_index;
+	end
+	if opts.mime_map == nil then
+		opts.mime_map = mime_map;
+	end
+	if opts.cache_size == nil then
+		opts.cache_size = cache_size;
 	end
-
-	return serve_file;
+	if opts.cache_max_file_size == nil then
+		opts.cache_max_file_size = cache_max_file_size;
+	end
+	if opts.index_files == nil then
+		opts.index_files = dir_indices;
+	end
+	module:log("warn", "%s should be updated to use 'net.http.files' instead of mod_http_files", get_calling_module());
+	return fileserver.serve(opts);
 end
 
 function wrap_route(routes)
+	module:log("debug", "%s should be updated to use 'net.http.files' instead of mod_http_files", get_calling_module());
 	for route,handler in pairs(routes) do
 		if type(handler) ~= "function" then
-			routes[route] = serve(handler);
+			routes[route] = fileserver.serve(handler);
 		end
 	end
 	return routes;
 end
 
-if base_path then
-	module:provides("http", {
-		route = {
-			["GET /*"] = serve {
-				path = base_path;
-				directory_index = directory_index;
-			}
-		};
-	});
-else
-	module:log("debug", "http_files_dir not set, assuming use by some other module");
-end
-
+module:provides("http", {
+	route = {
+		["GET /*"] = fileserver.serve({
+			path = base_path;
+			directory_index = directory_index;
+			mime_map = mime_map;
+			cache_size = cache_size;
+			cache_max_file_size = cache_max_file_size;
+			index_files = dir_indices;
+		});
+	};
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_http_openmetrics.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,60 @@
+-- Export statistics in OpenMetrics format
+--
+-- Copyright (C) 2014 Daurnimator
+-- Copyright (C) 2018 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
+-- Copyright (C) 2021 Jonas Schäfer <jonas@zombofant.net>
+--
+-- This module is MIT/X11 licensed.
+
+module:set_global();
+
+local statsman = require "core.statsmanager";
+local ip = require "util.ip";
+
+local get_metric_registry = statsman.get_metric_registry;
+local collect = statsman.collect;
+
+local get_metrics;
+
+local permitted_ips = module:get_option_set("openmetrics_allow_ips", { "::1", "127.0.0.1" });
+local permitted_cidr = module:get_option_string("openmetrics_allow_cidr");
+
+local function is_permitted(request)
+	local ip_raw = request.ip;
+	if permitted_ips:contains(ip_raw) or
+	   (permitted_cidr and ip.match(ip.new_ip(ip_raw), ip.parse_cidr(permitted_cidr))) then
+		return true;
+	end
+	return false;
+end
+
+function get_metrics(event)
+	if not is_permitted(event.request) then
+		return 403; -- Forbidden
+	end
+
+	local response = event.response;
+	response.headers.content_type = "application/openmetrics-text; version=0.0.4";
+
+	if collect then
+		-- Ensure to get up-to-date samples when running in manual mode
+		collect()
+	end
+
+	local registry = get_metric_registry()
+	if registry == nil then
+		response.headers.content_type = "text/plain; charset=utf-8"
+		response.status_code = 404
+		return "No statistics provider configured\n"
+	end
+
+	return registry:render();
+end
+
+module:depends "http";
+module:provides("http", {
+	default_path = "metrics";
+	route = {
+		GET = get_metrics;
+	};
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_invites.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,340 @@
+local id = require "util.id";
+local it = require "util.iterators";
+local url = require "socket.url";
+local jid_node = require "util.jid".node;
+local jid_split = require "util.jid".split;
+
+local default_ttl = module:get_option_number("invite_expiry", 86400 * 7);
+
+local token_storage;
+if prosody.process_type == "prosody" or prosody.shutdown then
+	token_storage = module:open_store("invite_token", "map");
+end
+
+local function get_uri(action, jid, token, params) --> string
+	return url.build({
+			scheme = "xmpp",
+			path = jid,
+			query = action..";preauth="..token..(params and (";"..params) or ""),
+		});
+end
+
+local function create_invite(invite_action, invite_jid, allow_registration, additional_data, ttl, reusable)
+	local token = id.medium();
+
+	local created_at = os.time();
+	local expires = created_at + (ttl or default_ttl);
+
+	local invite_params = (invite_action == "roster" and allow_registration) and "ibr=y" or nil;
+
+	local invite = {
+		type = invite_action;
+		jid = invite_jid;
+
+		token = token;
+		allow_registration = allow_registration;
+		additional_data = additional_data;
+
+		uri = get_uri(invite_action, invite_jid, token, invite_params);
+
+		created_at = created_at;
+		expires = expires;
+
+		reusable = reusable;
+	};
+
+	module:fire_event("invite-created", invite);
+
+	if allow_registration then
+		local ok, err = token_storage:set(nil, token, invite);
+		if not ok then
+			module:log("warn", "Failed to store account invite: %s", err);
+			return nil, "internal-server-error";
+		end
+	end
+
+	if invite_action == "roster" then
+		local username = jid_node(invite_jid);
+		local ok, err = token_storage:set(username, token, expires);
+		if not ok then
+			module:log("warn", "Failed to store subscription invite: %s", err);
+			return nil, "internal-server-error";
+		end
+	end
+
+	return invite;
+end
+
+-- Create invitation to register an account (optionally restricted to the specified username)
+function create_account(account_username, additional_data, ttl) --luacheck: ignore 131/create_account
+	local jid = account_username and (account_username.."@"..module.host) or module.host;
+	return create_invite("register", jid, true, additional_data, ttl);
+end
+
+-- Create invitation to reset the password for an account
+function create_account_reset(account_username, ttl) --luacheck: ignore 131/create_account_reset
+	return create_account(account_username, { allow_reset = account_username }, ttl or 86400);
+end
+
+-- Create invitation to become a contact of a local user
+function create_contact(username, allow_registration, additional_data, ttl) --luacheck: ignore 131/create_contact
+	return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl);
+end
+
+-- Create invitation to register an account and join a user group
+-- If explicit ttl is passed, invite is valid for multiple signups
+-- during that time period
+function create_group(group_ids, additional_data, ttl) --luacheck: ignore 131/create_group
+	local merged_additional_data = {
+		groups = group_ids;
+	};
+	if additional_data then
+		for k, v in pairs(additional_data) do
+			merged_additional_data[k] = v;
+		end
+	end
+	return create_invite("register", module.host, true, merged_additional_data, ttl, not not ttl);
+end
+
+-- Iterates pending (non-expired, unused) invites that allow registration
+function pending_account_invites() --luacheck: ignore 131/pending_account_invites
+	local store = module:open_store("invite_token");
+	local now = os.time();
+	local function is_valid_invite(_, invite)
+		return invite.expires > now;
+	end
+	return it.filter(is_valid_invite, pairs(store:get(nil) or {}));
+end
+
+function get_account_invite_info(token) --luacheck: ignore 131/get_account_invite_info
+	if not token then
+		return nil, "no-token";
+	end
+
+	-- Fetch from host store (account invite)
+	local token_info = token_storage:get(nil, token);
+	if not token_info then
+		return nil, "token-invalid";
+	elseif os.time() > token_info.expires then
+		return nil, "token-expired";
+	end
+
+	return token_info;
+end
+
+function delete_account_invite(token) --luacheck: ignore 131/delete_account_invite
+	if not token then
+		return nil, "no-token";
+	end
+
+	return token_storage:set(nil, token, nil);
+end
+
+local valid_invite_methods = {};
+local valid_invite_mt = { __index = valid_invite_methods };
+
+function valid_invite_methods:use()
+	if self.reusable then
+		return true;
+	end
+
+	if self.username then
+		-- Also remove the contact invite if present, on the
+		-- assumption that they now have a mutual subscription
+		token_storage:set(self.username, self.token, nil);
+	end
+	token_storage:set(nil, self.token, nil);
+
+	return true;
+end
+
+-- Get a validated invite (or nil, err). Must call :use() on the
+-- returned invite after it is actually successfully used
+-- For "roster" invites, the username of the local user (who issued
+-- the invite) must be passed.
+-- If no username is passed, but the registration is a roster invite
+-- from a local user, the "inviter" field of the returned invite will
+-- be set to their username.
+function get(token, username)
+	if not token then
+		return nil, "no-token";
+	end
+
+	local valid_until, inviter;
+
+	-- Fetch from host store (account invite)
+	local token_info = token_storage:get(nil, token);
+
+	if username then -- token being used for subscription
+		-- Fetch from user store (subscription invite)
+		valid_until = token_storage:get(username, token);
+	else -- token being used for account creation
+		valid_until = token_info and token_info.expires;
+		if token_info and token_info.type == "roster" then
+			username = jid_node(token_info.jid);
+			inviter = username;
+		end
+	end
+
+	if not valid_until then
+		module:log("debug", "Got unknown token: %s", token);
+		return nil, "token-invalid";
+	elseif os.time() > valid_until then
+		module:log("debug", "Got expired token: %s", token);
+		return nil, "token-expired";
+	end
+
+	return setmetatable({
+		token = token;
+		username = username;
+		inviter = inviter;
+		type = token_info and token_info.type or "roster";
+		uri = token_info and token_info.uri or get_uri("roster", username.."@"..module.host, token);
+		additional_data = token_info and token_info.additional_data or nil;
+		reusable = token_info.reusable;
+	}, valid_invite_mt);
+end
+
+function use(token) --luacheck: ignore 131/use
+	local invite = get(token);
+	return invite and invite:use();
+end
+
+--- shell command
+do
+	-- Since the console is global this overwrites the command for
+	-- each host it's loaded on, but this should be fine.
+
+	local get_module = require "core.modulemanager".get_module;
+
+	local console_env = module:shared("/*/admin_shell/env");
+
+	-- luacheck: ignore 212/self
+	console_env.invite = {};
+	function console_env.invite:create_account(user_jid)
+		local username, host = jid_split(user_jid);
+		local mod_invites, err = get_module(host, "invites");
+		if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
+		local invite, err = mod_invites.create_account(username);
+		if not invite then return nil, err; end
+		return true, invite.uri;
+	end
+
+	function console_env.invite:create_contact(user_jid, allow_registration)
+		local username, host = jid_split(user_jid);
+		local mod_invites, err = get_module(host, "invites");
+		if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
+		local invite, err = mod_invites.create_contact(username, allow_registration);
+		if not invite then return nil, err; end
+		return true, invite.uri;
+	end
+end
+
+--- prosodyctl command
+function module.command(arg)
+	if #arg < 2 or arg[1] ~= "generate" then
+		print("usage: prosodyctl mod_"..module.name.." generate example.com");
+		return 2;
+	end
+	table.remove(arg, 1); -- pop command
+
+	local sm = require "core.storagemanager";
+	local mm = require "core.modulemanager";
+
+	local host = arg[1];
+	assert(prosody.hosts[host], "Host "..tostring(host).." does not exist");
+	sm.initialize_host(host);
+	table.remove(arg, 1); -- pop host
+	module.host = host; --luacheck: ignore 122/module
+	token_storage = module:open_store("invite_token", "map");
+
+	-- Load mod_invites
+	local invites = module:depends("invites");
+	-- Optional community module that if used, needs to be loaded here
+	local invites_page_module = module:get_option_string("invites_page_module", "invites_page");
+	if mm.get_modules_for_host(host):contains(invites_page_module) then
+		module:depends(invites_page_module);
+	end
+
+	local allow_reset;
+	local roles;
+	local groups = {};
+
+	while #arg > 0 do
+		local value = arg[1];
+		table.remove(arg, 1);
+		if value == "--help" then
+			print("usage: prosodyctl mod_"..module.name.." generate DOMAIN --reset USERNAME")
+			print("usage: prosodyctl mod_"..module.name.." generate DOMAIN [--admin] [--role ROLE] [--group GROUPID]...")
+			print()
+			print("This command has two modes: password reset and new account.")
+			print("If --reset is given, the command operates in password reset mode and in new account mode otherwise.")
+			print()
+			print("required arguments in password reset mode:")
+			print()
+			print("    --reset USERNAME  Generate a password reset link for the given USERNAME.")
+			print()
+			print("optional arguments in new account mode:")
+			print()
+			print("    --admin           Make the new user privileged")
+			print("                      Equivalent to --role prosody:admin")
+			print("    --role ROLE       Grant the given ROLE to the new user")
+			print("    --group GROUPID   Add the user to the group with the given ID")
+			print("                      Can be specified multiple times")
+			print()
+			print("--role and --admin override each other; the last one wins")
+			print("--group can be specified multiple times; the user will be added to all groups.")
+			print()
+			print("--reset and the other options cannot be mixed.")
+			return 2
+		elseif value == "--reset" then
+			local nodeprep = require "util.encodings".stringprep.nodeprep;
+			local username = nodeprep(arg[1])
+			table.remove(arg, 1);
+			if not username then
+				print("Please supply a valid username to generate a reset link for");
+				return 2;
+			end
+			allow_reset = username;
+		elseif value == "--admin" then
+			roles = { ["prosody:admin"] = true };
+		elseif value == "--role" then
+			local rolename = arg[1];
+			if not rolename then
+				print("Please supply a role name");
+				return 2;
+			end
+			roles = { [rolename] = true };
+			table.remove(arg, 1);
+		elseif value == "--group" or value == "-g" then
+			local groupid = arg[1];
+			if not groupid then
+				print("Please supply a group ID")
+				return 2;
+			end
+			table.insert(groups, groupid);
+			table.remove(arg, 1);
+		else
+			print("unexpected argument: "..value)
+		end
+	end
+
+	local invite;
+	if allow_reset then
+		if roles then
+			print("--role/--admin and --reset are mutually exclusive")
+			return 2;
+		end
+		if #groups > 0 then
+			print("--group and --reset are mutually exclusive")
+		end
+		invite = assert(invites.create_account_reset(allow_reset));
+	else
+		invite = assert(invites.create_account(nil, {
+			roles = roles,
+			groups = groups
+		}));
+	end
+
+	print(invite.landing_page or invite.uri);
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_invites_adhoc.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,130 @@
+-- XEP-0401: Easy User Onboarding
+local dataforms = require "util.dataforms";
+local datetime = require "util.datetime";
+local split_jid = require "util.jid".split;
+local usermanager = require "core.usermanager";
+
+local new_adhoc = module:require("adhoc").new;
+
+-- Whether local users can invite other users to create an account on this server
+local allow_user_invites = module:get_option_boolean("allow_user_invites", false);
+-- Who can see and use the contact invite command. It is strongly recommended to
+-- keep this available to all local users. To allow/disallow invite-registration
+-- on the server, use the option above instead.
+local allow_contact_invites = module:get_option_boolean("allow_contact_invites", true);
+
+local allow_user_invite_roles = module:get_option_set("allow_user_invites_by_roles");
+local deny_user_invite_roles = module:get_option_set("deny_user_invites_by_roles");
+
+local invites;
+if prosody.shutdown then -- COMPAT hack to detect prosodyctl
+	invites = module:depends("invites");
+end
+
+local invite_result_form = dataforms.new({
+		title = "Your invite has been created",
+		{
+			name = "url" ;
+			var = "landing-url";
+			label = "Invite web page";
+			desc = "Share this link";
+		},
+		{
+			name = "uri";
+			label = "Invite URI";
+			desc = "This alternative link can be opened with some XMPP clients";
+		},
+		{
+			name = "expire";
+			label = "Invite valid until";
+		},
+	});
+
+-- This is for checking if the specified JID may create invites
+-- that allow people to register accounts on this host.
+local function may_invite_new_users(jid)
+	if usermanager.get_roles then
+		local user_roles = usermanager.get_roles(jid, module.host);
+		if not user_roles then
+			-- User has no roles we can check, just return default
+			return allow_user_invites;
+		end
+
+		if user_roles["prosody:admin"] then
+			return true;
+		end
+		if allow_user_invite_roles then
+			for allowed_role in allow_user_invite_roles do
+				if user_roles[allowed_role] then
+					return true;
+				end
+			end
+		end
+		if deny_user_invite_roles then
+			for denied_role in deny_user_invite_roles do
+				if user_roles[denied_role] then
+					return false;
+				end
+			end
+		end
+	elseif usermanager.is_admin(jid, module.host) then -- COMPAT w/0.11
+		return true; -- Admins may always create invitations
+	end
+	-- No role matches, so whatever the default is
+	return allow_user_invites;
+end
+
+module:depends("adhoc");
+
+-- This command is available to all local users, even if allow_user_invites = false
+-- If allow_user_invites is false, creating an invite still works, but the invite will
+-- not be valid for registration on the current server, only for establishing a roster
+-- subscription.
+module:provides("adhoc", new_adhoc("Create new contact invite", "urn:xmpp:invite#invite",
+		function (_, data)
+			local username, host = split_jid(data.from);
+			if host ~= module.host then
+				return {
+					status = "completed";
+					error = {
+						message = "This command is only available to users of "..module.host;
+					};
+				};
+			end
+			local invite = invites.create_contact(username, may_invite_new_users(data.from), {
+				source = data.from
+			});
+			--TODO: check errors
+			return {
+				status = "completed";
+				form = {
+					layout = invite_result_form;
+					values = {
+						uri = invite.uri;
+						url = invite.landing_page;
+						expire = datetime.datetime(invite.expires);
+					};
+				};
+			};
+		end, allow_contact_invites and "local_user" or "admin"));
+
+-- This is an admin-only command that creates a new invitation suitable for registering
+-- a new account. It does not add the new user to the admin's roster.
+module:provides("adhoc", new_adhoc("Create new account invite", "urn:xmpp:invite#create-account",
+		function (_, data)
+			local invite = invites.create_account(nil, {
+				source = data.from
+			});
+			--TODO: check errors
+			return {
+				status = "completed";
+				form = {
+					layout = invite_result_form;
+					values = {
+						uri = invite.uri;
+						url = invite.landing_page;
+						expire = datetime.datetime(invite.expires);
+					};
+				};
+			};
+		end, "admin"));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_invites_register.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,165 @@
+local st = require "util.stanza";
+local jid_split = require "util.jid".split;
+local jid_bare = require "util.jid".bare;
+local rostermanager = require "core.rostermanager";
+
+local require_encryption = module:get_option_boolean("c2s_require_encryption",
+	module:get_option_boolean("require_encryption", true));
+local invite_only = module:get_option_boolean("registration_invite_only", true);
+
+local invites;
+if prosody.process_type == "prosody" then
+	invites = module:depends("invites");
+
+	if invite_only then
+		module:depends("register_ibr");
+	end
+end
+
+local legacy_invite_stream_feature = st.stanza("register", { xmlns = "urn:xmpp:invite" }):up();
+local invite_stream_feature = st.stanza("register", { xmlns = "urn:xmpp:ibr-token:0" }):up();
+module:hook("stream-features", function(event)
+	local session, features = event.origin, event.features;
+
+	-- Advertise to unauthorized clients only.
+	if session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then
+		return
+	end
+
+	features:add_child(legacy_invite_stream_feature);
+	features:add_child(invite_stream_feature);
+end);
+
+-- XEP-0379: Pre-Authenticated Roster Subscription
+module:hook("presence/bare", function (event)
+	local stanza = event.stanza;
+	if stanza.attr.type ~= "subscribe" then return end
+
+	local preauth = stanza:get_child("preauth", "urn:xmpp:pars:0");
+	if not preauth then return end
+	local token = preauth.attr.token;
+	if not token then return end
+
+	local username, host = jid_split(stanza.attr.to);
+
+	local invite, err = invites.get(token, username);
+
+	if not invite then
+		module:log("debug", "Got invalid token, error: %s", err);
+		return;
+	end
+
+	local contact = jid_bare(stanza.attr.from);
+
+	module:log("debug", "Approving inbound subscription to %s from %s", username, contact);
+	if rostermanager.set_contact_pending_in(username, host, contact, stanza) then
+		if rostermanager.subscribed(username, host, contact) then
+			invite:use();
+			rostermanager.roster_push(username, host, contact);
+
+			-- Send back a subscription request (goal is mutual subscription)
+			if not rostermanager.is_user_subscribed(username, host, contact)
+			and not rostermanager.is_contact_pending_out(username, host, contact) then
+				module:log("debug", "Sending automatic subscription request to %s from %s", contact, username);
+				if rostermanager.set_contact_pending_out(username, host, contact) then
+					rostermanager.roster_push(username, host, contact);
+					module:send(st.presence({type = "subscribe", from = username.."@"..host, to = contact }));
+				else
+					module:log("warn", "Failed to set contact pending out for %s", username);
+				end
+			end
+		end
+	end
+end, 1);
+
+-- Client is submitting a preauth token to allow registration
+module:hook("stanza/iq/urn:xmpp:pars:0:preauth", function(event)
+	local preauth = event.stanza.tags[1];
+	local token = preauth.attr.token;
+	local validated_invite = invites.get(token);
+	if not validated_invite then
+		local reply = st.error_reply(event.stanza, "cancel", "forbidden", "The invite token is invalid or expired");
+		event.origin.send(reply);
+		return true;
+	end
+	event.origin.validated_invite = validated_invite;
+	local reply = st.reply(event.stanza);
+	event.origin.send(reply);
+	return true;
+end);
+
+-- Registration attempt - ensure a valid preauth token has been supplied
+module:hook("user-registering", function (event)
+	local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
+	if invite_only and not validated_invite then
+		event.allowed = false;
+		event.reason = "Registration on this server is through invitation only";
+		return;
+	elseif not validated_invite then
+		-- This registration is not using an invite, but
+		-- the server is not in invite-only mode, so nothing
+		-- for this module to do...
+		return;
+	end
+	if validated_invite and validated_invite.additional_data and validated_invite.additional_data.allow_reset then
+		event.allow_reset = validated_invite.additional_data.allow_reset;
+	end
+end);
+
+-- Make a *one-way* subscription. User will see when contact is online,
+-- contact will not see when user is online.
+function subscribe(host, user_username, contact_username)
+	local user_jid = user_username.."@"..host;
+	local contact_jid = contact_username.."@"..host;
+	-- Update user's roster to say subscription request is pending...
+	rostermanager.set_contact_pending_out(user_username, host, contact_jid);
+	-- Update contact's roster to say subscription request is pending...
+	rostermanager.set_contact_pending_in(contact_username, host, user_jid);
+	-- Update contact's roster to say subscription request approved...
+	rostermanager.subscribed(contact_username, host, user_jid);
+	-- Update user's roster to say subscription request approved...
+	rostermanager.process_inbound_subscription_approval(user_username, host, contact_jid);
+end
+
+-- Make a mutual subscription between jid1 and jid2. Each JID will see
+-- when the other one is online.
+function subscribe_both(host, user1, user2)
+	subscribe(host, user1, user2);
+	subscribe(host, user2, user1);
+end
+
+-- Registration successful, if there was a preauth token, mark it as used
+module:hook("user-registered", function (event)
+	local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
+	if not validated_invite then
+		return;
+	end
+	local inviter_username = validated_invite.inviter;
+	local contact_username = event.username;
+	validated_invite:use();
+
+	if inviter_username then
+		module:log("debug", "Creating mutual subscription between %s and %s", inviter_username, contact_username);
+		subscribe_both(module.host, inviter_username, contact_username);
+		rostermanager.roster_push(inviter_username, module.host, contact_username.."@"..module.host);
+	end
+
+	if validated_invite.additional_data then
+		module:log("debug", "Importing roles from invite");
+		local roles = validated_invite.additional_data.roles;
+		if roles then
+			module:open_store("roles"):set(contact_username, roles);
+		end
+	end
+end);
+
+-- Equivalent of user-registered but for when the account already existed
+-- (i.e. password reset)
+module:hook("user-password-reset", function (event)
+	local validated_invite = event.validated_invite or (event.session and event.session.validated_invite);
+	if not validated_invite then
+		return;
+	end
+	validated_invite:use();
+end);
+
--- a/plugins/mod_lastactivity.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_lastactivity.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -30,7 +30,7 @@
 	if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
 		local seconds, text = "0", "";
 		if map[username] then
-			seconds = tostring(os.difftime(os.time(), map[username].t));
+			seconds = string.format("%d", os.difftime(os.time(), map[username].t));
 			text = map[username].s;
 		end
 		origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text));
--- a/plugins/mod_legacyauth.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_legacyauth.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,7 +12,7 @@
 local t_concat = table.concat;
 
 local secure_auth_only = module:get_option("c2s_require_encryption",
-	module:get_option("require_encryption"))
+	module:get_option("require_encryption", true))
 	or not(module:get_option("allow_unencrypted_plain_auth"));
 
 local sessionmanager = require "core.sessionmanager";
@@ -78,8 +78,10 @@
 					session:close(); -- FIXME undo resource bind and auth instead of closing the session?
 					return true;
 				end
+				session.send(st.reply(stanza));
+			else
+				session.send(st.error_reply(stanza, "auth", "not-authorized", err));
 			end
-			session.send(st.reply(stanza));
 		else
 			session.send(st.error_reply(stanza, "auth", "not-authorized"));
 		end
--- a/plugins/mod_limits.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_limits.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -32,7 +32,7 @@
 	end
 	local n_burst = tonumber(burst);
 	if burst and not n_burst then
-		module:log("error", "Unable to parse burst for %s: %q, using default burst interval (%ds)", sess_type, tostring(burst), default_burst);
+		module:log("error", "Unable to parse burst for %s: %q, using default burst interval (%ds)", sess_type, burst, default_burst);
 	end
 	return n_burst or default_burst;
 end
@@ -60,18 +60,18 @@
 local default_filter_set = {};
 
 function default_filter_set.bytes_in(bytes, session)
-  local sess_throttle = session.throttle;
-  if sess_throttle then
-    local ok, balance, outstanding = sess_throttle:poll(#bytes, true);
+	local sess_throttle = session.throttle;
+	if sess_throttle then
+		local ok, _, outstanding = sess_throttle:poll(#bytes, true);
 		if not ok then
-      session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding);
+			session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding);
 			outstanding = ceil(outstanding);
 			session.conn:pause(); -- Read no more data from the connection until there is no outstanding data
 			local outstanding_data = bytes:sub(-outstanding);
 			bytes = bytes:sub(1, #bytes-outstanding);
 			timer.add_task(limits_resolution, function ()
 				if not session.conn then return; end
-        if sess_throttle:peek(#outstanding_data) then
+				if sess_throttle:peek(#outstanding_data) then
 					session.log("debug", "Resuming paused session");
 					session.conn:resume();
 				end
@@ -93,8 +93,13 @@
 	local session_type = session.type:match("^[^_]+");
 	local filter_set, opts = type_filters[session_type], limits[session_type];
 	if opts then
-		session.throttle = throttle.create(opts.bytes_per_second * opts.burst_seconds, opts.burst_seconds);
-		filters.add_filter(session, "bytes/in", filter_set.bytes_in, 1000);
+		if session.conn and session.conn.setlimit then
+			session.conn:setlimit(opts.bytes_per_second);
+			-- Currently no burst support
+		else
+			session.throttle = throttle.create(opts.bytes_per_second * opts.burst_seconds, opts.burst_seconds);
+			filters.add_filter(session, "bytes/in", filter_set.bytes_in, 1000);
+		end
 	end
 end
 
@@ -105,3 +110,44 @@
 function module.unload()
 	filters.remove_filter_hook(filter_hook);
 end
+
+function unlimited(session)
+	local session_type = session.type:match("^[^_]+");
+	if session.conn and session.conn.setlimit then
+		session.conn:setlimit(0);
+		-- Currently no burst support
+	else
+		local filter_set = type_filters[session_type];
+		filters.remove_filter(session, "bytes/in", filter_set.bytes_in);
+		session.throttle = nil;
+	end
+end
+
+function module.add_host(module)
+	local unlimited_jids = module:get_option_inherited_set("unlimited_jids", {});
+
+	if not unlimited_jids:empty() then
+		module:hook("authentication-success", function (event)
+			local session = event.session;
+			local jid = session.username .. "@" .. session.host;
+			if unlimited_jids:contains(jid) then
+				unlimited(session);
+			end
+		end);
+
+		module:hook("s2sout-established", function (event)
+			local session = event.session;
+			if unlimited_jids:contains(session.to_host) then
+				unlimited(session);
+			end
+		end);
+
+		module:hook("s2sin-established", function (event)
+			local session = event.session;
+			if session.from_host and unlimited_jids:contains(session.from_host) then
+				unlimited(session);
+			end
+		end);
+
+	end
+end
--- a/plugins/mod_mam/mod_mam.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_mam/mod_mam.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,7 +1,7 @@
 -- Prosody IM
 -- Copyright (C) 2008-2017 Matthew Wild
 -- Copyright (C) 2008-2017 Waqas Hussain
--- Copyright (C) 2011-2017 Kim Alvefur
+-- Copyright (C) 2011-2021 Kim Alvefur
 --
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
@@ -10,6 +10,7 @@
 --
 
 local xmlns_mam     = "urn:xmpp:mam:2";
+local xmlns_mam_ext = "urn:xmpp:mam:2#extended";
 local xmlns_delay   = "urn:xmpp:delay";
 local xmlns_forward = "urn:xmpp:forward:0";
 local xmlns_st_id   = "urn:xmpp:sid:0";
@@ -23,8 +24,10 @@
 local prefs_from_stanza = module:require"mamprefsxml".fromstanza;
 local jid_bare = require "util.jid".bare;
 local jid_split = require "util.jid".split;
+local jid_resource = require "util.jid".resource;
 local jid_prepped_split = require "util.jid".prepped_split;
 local dataform = require "util.dataforms".new;
+local get_form_type = require "util.dataforms".get_type;
 local host = module.host;
 
 local rm_load_roster = require "core.rostermanager".load_roster;
@@ -33,13 +36,17 @@
 local tostring = tostring;
 local time_now = os.time;
 local m_min = math.min;
-local timestamp, timestamp_parse, datestamp = import( "util.datetime", "datetime", "parse", "date");
+local timestamp, datestamp = import( "util.datetime", "datetime", "date");
 local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
 local strip_tags = module:get_option_set("dont_archive_namespaces", { "http://jabber.org/protocol/chatstates" });
 
 local archive_store = module:get_option_string("archive_store", "archive");
 local archive = module:open_store(archive_store, "archive");
 
+local cleanup_after = module:get_option_string("archive_expires_after", "1w");
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000);
+local archive_truncate = math.floor(archive_item_limit * 0.99);
+
 if not archive.find then
 	error("mod_"..(archive._provided_by or archive.name and "storage_"..archive.name).." does not support archiving\n"
 		.."See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information");
@@ -47,7 +54,7 @@
 local use_total = module:get_option_boolean("mam_include_total", true);
 
 function schedule_cleanup()
-	-- replaced by non-noop later if cleanup is enabled
+	-- replaced later if cleanup is enabled
 end
 
 -- Handle prefs.
@@ -70,12 +77,22 @@
 end);
 
 local query_form = dataform {
-	{ name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; };
-	{ name = "with"; type = "jid-single"; };
-	{ name = "start"; type = "text-single" };
-	{ name = "end"; type = "text-single"; };
+	{ name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam };
+	{ name = "with"; type = "jid-single" };
+	{ name = "start"; type = "text-single"; datatype = "xs:dateTime" };
+	{ name = "end"; type = "text-single"; datatype = "xs:dateTime" };
 };
 
+if archive.caps and archive.caps.full_id_range then
+	table.insert(query_form, { name = "before-id"; type = "text-single"; });
+	table.insert(query_form, { name = "after-id"; type = "text-single"; });
+end
+
+if archive.caps and archive.caps.ids then
+	table.insert(query_form, { name = "ids"; type = "list-multi"; });
+end
+
+
 -- Serve form
 module:hook("iq-get/self/"..xmlns_mam..":query", function(event)
 	local origin, stanza = event.origin, event.stanza;
@@ -95,52 +112,67 @@
 	get_prefs(origin.username, true);
 
 	-- Search query parameters
-	local qwith, qstart, qend;
+	local qwith, qstart, qend, qbefore, qafter, qids;
 	local form = query:get_child("x", "jabber:x:data");
 	if form then
-		local err;
+		local form_type, err = get_form_type(form);
+		if not form_type then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err));
+			return true;
+		elseif form_type ~= xmlns_mam then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam.."'"));
+			return true;
+		end
 		form, err = query_form:data(form);
 		if err then
 			origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err))));
 			return true;
 		end
 		qwith, qstart, qend = form["with"], form["start"], form["end"];
+		qbefore, qafter = form["before-id"], form["after-id"];
+		qids = form["ids"];
 		qwith = qwith and jid_bare(qwith); -- dataforms does jidprep
 	end
 
-	if qstart or qend then -- Validate timestamps
-		local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend));
-		if (qstart and not vstart) or (qend and not vend) then
-			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp"))
-			return true;
-		end
-		qstart, qend = vstart, vend;
-	end
-
-	module:log("debug", "Archive query, id %s with %s from %s until %s",
-		tostring(qid), qwith or "anyone",
-		qstart and timestamp(qstart) or "the dawn of time",
-		qend and timestamp(qend) or "now");
-
 	-- RSM stuff
 	local qset = rsm.get(query);
 	local qmax = m_min(qset and qset.max or default_max_items, max_max_items);
 	local reverse = qset and qset.before or false;
-	local before, after = qset and qset.before, qset and qset.after;
+	local before, after = qset and qset.before or qbefore, qset and qset.after or qafter;
 	if type(before) ~= "string" then before = nil; end
 
+
+	module:log("debug", "Archive query by %s id=%s with=%s when=%s...%s rsm=%q",
+		origin.username,
+		qid or stanza.attr.id,
+		qwith or "*",
+		qstart and timestamp(qstart) or "",
+		qend and timestamp(qend) or "",
+		qset);
+
+	-- A reverse query needs to be flipped
+	local flip = reverse;
+	-- A flip-page query needs to be the opposite of that.
+	if query:get_child("flip-page") then flip = not flip end
+
 	-- Load all the data!
 	local data, err = archive:find(origin.username, {
 		start = qstart; ["end"] = qend; -- Time range
 		with = qwith;
 		limit = qmax == 0 and 0 or qmax + 1;
 		before = before; after = after;
+		ids = qids;
 		reverse = reverse;
 		total = use_total or qmax == 0;
 	});
 
 	if not data then
-		origin.send(st.error_reply(stanza, "cancel", "internal-server-error", err));
+		module:log("debug", "Archive query id=%s failed: %s", qid or stanza.attr.id, err);
+		if err == "item-not-found" then
+			origin.send(st.error_reply(stanza, "modify", "item-not-found"));
+		else
+			origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+		end
 		return true;
 	end
 	local total = tonumber(err);
@@ -175,27 +207,64 @@
 		if not first then first = id; end
 		last = id;
 
-		if reverse then
+		if flip then
 			results[count] = fwd_st;
 		else
 			origin.send(fwd_st);
 		end
 	end
 
-	if reverse then
+	if flip then
 		for i = #results, 1, -1 do
 			origin.send(results[i]);
 		end
+	end
+	if reverse then
 		first, last = last, first;
 	end
 
-	-- That's all folks!
-	module:log("debug", "Archive query %s completed", tostring(qid));
-
 	origin.send(st.reply(stanza)
-		:tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete })
+		:tag("fin", { xmlns = xmlns_mam, complete = complete })
 			:add_child(rsm.generate {
 				first = first, last = last, count = total }));
+
+	-- That's all folks!
+	module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1);
+	return true;
+end);
+
+module:hook("iq-get/self/"..xmlns_mam..":metadata", function (event)
+	local origin, stanza = event.origin, event.stanza;
+
+	local reply = st.reply(stanza):tag("metadata", { xmlns = xmlns_mam });
+
+	do
+		local first = archive:find(origin.username, { limit = 1 });
+		if not first then
+			origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+			return true;
+		end
+
+		local id, _, when = first();
+		if id then
+			reply:tag("start", { id = id, timestamp = timestamp(when) }):up();
+		end
+	end
+
+	do
+		local last = archive:find(origin.username, { limit = 1, reverse = true });
+		if not last then
+			origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+			return true;
+		end
+
+		local id, _, when = last();
+		if id then
+			reply:tag("end", { id = id, timestamp = timestamp(when) }):up();
+		end
+	end
+
+	origin.send(reply);
 	return true;
 end);
 
@@ -213,13 +282,13 @@
 	end
 	local prefs = get_prefs(user);
 	local rule = prefs[who];
-	module:log("debug", "%s's rule for %s is %s", user, who, tostring(rule));
+	module:log("debug", "%s's rule for %s is %s", user, who, rule);
 	if rule ~= nil then
 		return rule;
 	end
 	-- Below could be done by a metatable
 	local default = prefs[false];
-	module:log("debug", "%s's default rule is %s", user, tostring(default));
+	module:log("debug", "%s's default rule is %s", user, default);
 	if default == "roster" then
 		return has_in_roster(user, who);
 	end
@@ -242,16 +311,90 @@
 	return stanza;
 end
 
+local function should_store(stanza) --> boolean, reason: string
+	local st_type = stanza.attr.type or "normal";
+	-- FIXME pass direction of stanza and use that along with bare/full JID addressing
+	-- for more accurate MUC / type=groupchat check
+
+	if st_type == "headline" then
+		-- Headline messages are ephemeral by definition
+		return false, "headline";
+	end
+	if st_type == "error" then
+		-- Errors not sent sent from a local client
+		-- Why would a client send an error anyway?
+		if jid_resource(stanza.attr.to) then
+			-- Store delivery failure notifications so you know if your own messages
+			-- were not delivered.
+			return true, "bounce";
+		else
+			-- Skip errors for messages that come from your account, such as PEP
+			-- notifications.
+			return false, "bounce";
+		end
+	end
+	if st_type == "groupchat" then
+		-- MUC messages always go to the full JID, usually archived by the MUC
+		return false, "groupchat";
+	end
+	if stanza:get_child("no-store", "urn:xmpp:hints")
+	or stanza:get_child("no-permanent-store", "urn:xmpp:hints") then
+		-- XXX Experimental XEP
+		return false, "hint";
+	end
+	if stanza:get_child("store", "urn:xmpp:hints") then
+		return true, "hint";
+	end
+	if stanza:get_child("body") then
+		return true, "body";
+	end
+	if stanza:get_child("subject") then
+		-- XXX Who would send a message with a subject but without a body?
+		return true, "subject";
+	end
+	if stanza:get_child("encryption", "urn:xmpp:eme:0") then
+		-- Since we can't know what an encrypted message contains, we assume it's important
+		-- XXX Experimental XEP
+		return true, "encrypted";
+	end
+	if stanza:get_child(nil, "urn:xmpp:receipts") then
+		-- If it's important enough to ask for a receipt then it's important enough to archive
+		-- and the same applies to the receipt
+		return true, "receipt";
+	end
+	if stanza:get_child(nil, "urn:xmpp:chat-markers:0") then
+		-- XXX Experimental XEP
+		return true, "marker";
+	end
+	if stanza:get_child("x", "jabber:x:conference")
+	or stanza:find("{http://jabber.org/protocol/muc#user}x/invite") then
+		return true, "invite";
+	end
+	if stanza:get_child(nil, "urn:xmpp:jingle-message:0") or stanza:get_child(nil, "urn:xmpp:jingle-message:1") then
+		-- XXX Experimental XEP
+		return true, "jingle call";
+	end
+
+	 -- The IM-NG thing to do here would be to return `not st_to_full`
+	 -- One day ...
+	return false, "default";
+end
+
+module:hook("archive-should-store", function (event)
+	local should, why = should_store(event.stanza);
+	event.reason = why;
+	return should;
+end, -1)
+
 -- Handle messages
 local function message_handler(event, c2s)
 	local origin, stanza = event.origin, event.stanza;
 	local log = c2s and origin.log or module._log;
-	local orig_type = stanza.attr.type or "normal";
 	local orig_from = stanza.attr.from;
 	local orig_to = stanza.attr.to or orig_from;
 	-- Stanza without 'to' are treated as if it was to their own bare jid
 
-	-- Whos storage do we put it in?
+	-- Whose storage do we put it in?
 	local store_user = c2s and origin.username or jid_split(orig_to);
 	-- And who are they chatting with?
 	local with = jid_bare(c2s and orig_to or orig_from);
@@ -259,19 +402,13 @@
 	-- Filter out <stanza-id> that claim to be from us
 	event.stanza = strip_stanza_id(stanza, store_user);
 
-	-- We store chat messages or normal messages that have a body
-	if not(orig_type == "chat" or (orig_type == "normal" and stanza:get_child("body")) ) then
-		log("debug", "Not archiving stanza: %s (type)", stanza:top_tag());
-		return;
-	end
+	local event_payload = { stanza = stanza; session = origin };
+	local should = module:fire_event("archive-should-store", event_payload);
+	local why = event_payload.reason;
 
-	-- or if hints suggest we shouldn't
-	if not stanza:get_child("store", "urn:xmpp:hints") then -- No hint telling us we should store
-		if stanza:get_child("no-permanent-store", "urn:xmpp:hints")
-			or stanza:get_child("no-store", "urn:xmpp:hints") then -- Hint telling us we should NOT store
-			log("debug", "Not archiving stanza: %s (hint)", stanza:top_tag());
-			return;
-		end
+	if not should then
+		log("debug", "Not archiving stanza: %s (%s)", stanza:top_tag(), event_payload.reason);
+		return;
 	end
 
 	local clone_for_storage;
@@ -294,10 +431,31 @@
 
 	-- Check with the users preferences
 	if shall_store(store_user, with) then
-		log("debug", "Archiving stanza: %s", stanza:top_tag());
+		log("debug", "Archiving stanza: %s (%s)", stanza:top_tag(), why);
 
 		-- And stash it
-		local ok, err = archive:append(store_user, nil, clone_for_storage, time_now(), with);
+		local time = time_now();
+		local ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
+		if not ok and err == "quota-limit" then
+			if type(cleanup_after) == "number" then
+				module:log("debug", "User '%s' over quota, cleaning archive", store_user);
+				local cleaned = archive:delete(store_user, {
+					["end"] = (os.time() - cleanup_after);
+				});
+				if cleaned then
+					ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
+				end
+			end
+			if not ok and (archive.caps and archive.caps.truncate) then
+				module:log("debug", "User '%s' over quota, truncating archive", store_user);
+				local truncated = archive:delete(store_user, {
+					truncate = archive_truncate;
+				});
+				if truncated then
+					ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
+				end
+			end
+		end
 		if ok then
 			local clone_for_other_handlers = st.clone(stanza);
 			local id = ok;
@@ -325,8 +483,25 @@
 module:hook("pre-message/bare", strip_stanza_id_after_other_events, -1);
 module:hook("pre-message/full", strip_stanza_id_after_other_events, -1);
 
-local cleanup_after = module:get_option_string("archive_expires_after", "1w");
-local cleanup_interval = module:get_option_number("archive_cleanup_interval", 4 * 60 * 60);
+-- Catch messages not stored by mod_offline and mark them as stored if they
+-- have been archived. This would generally only happen if mod_offline is
+-- disabled.  Otherwise the message would generate a delivery failure report,
+-- which would not be accurate because it has been archived.
+module:hook("message/offline/handle", function(event)
+	local stanza = event.stanza;
+	local user = event.username .. "@" .. host;
+	if stanza:get_child_with_attr("stanza-id", xmlns_st_id, "by", user) then
+		return true;
+	end
+end, -2);
+
+-- Don't broadcast offline messages to clients that have queried the archive.
+module:hook("message/offline/broadcast", function (event)
+	if event.origin.mam_requested then
+		return true;
+	end
+end);
+
 if cleanup_after ~= "never" then
 	local cleanup_storage = module:open_store("archive_cleanup");
 	local cleanup_map = module:open_store("archive_cleanup", "map");
@@ -352,18 +527,41 @@
 	-- messages, we collect the union of sets of users from dates that fall
 	-- outside the cleanup range.
 
-	local last_date = require "util.cache".new(module:get_option_number("archive_cleanup_date_cache_size", 1000));
-	function schedule_cleanup(username, date)
-		date = date or datestamp();
-		if last_date:get(username) == date then return end
-		local ok = cleanup_map:set(date, username, true);
-		if ok then
-			last_date:set(username, date);
+	if not (archive.caps and archive.caps.wildcard_delete) then
+		local last_date = require "util.cache".new(module:get_option_number("archive_cleanup_date_cache_size", 1000));
+		function schedule_cleanup(username, date)
+			date = date or datestamp();
+			if last_date:get(username) == date then return end
+			local ok = cleanup_map:set(date, username, true);
+			if ok then
+				last_date:set(username, date);
+			end
 		end
 	end
 
+	local cleanup_time = module:measure("cleanup", "times");
+
 	local async = require "util.async";
-	cleanup_runner = async.runner(function ()
+	module:daily("Remove expired messages", function ()
+		local cleanup_done = cleanup_time();
+
+		if archive.caps and archive.caps.wildcard_delete then
+			local ok, err = archive:delete(true, { ["end"] = os.time() - cleanup_after })
+			if ok then
+				local sum = tonumber(ok);
+				if sum then
+					module:log("info", "Deleted %d expired messages", sum);
+				else
+					-- driver did not tell
+					module:log("info", "Deleted all expired messages");
+				end
+			else
+				module:log("error", "Could not delete messages: %s", err);
+			end
+			cleanup_done();
+			return;
+		end
+
 		local users = {};
 		local cut_off = datestamp(os.time() - cleanup_after);
 		for date in cleanup_storage:users() do
@@ -397,12 +595,9 @@
 			wait();
 		end
 		module:log("info", "Deleted %d expired messages for %d users", sum, num_users);
+		cleanup_done();
 	end);
 
-	cleanup_task = module:add_timer(1, function ()
-		cleanup_runner:run(true);
-		return cleanup_interval;
-	end);
 else
 	module:log("debug", "Archive expiry disabled");
 	-- Don't ask the backend to count the potentially unbounded number of items,
@@ -417,8 +612,13 @@
 module:hook("message/bare", message_handler, 0);
 module:hook("message/full", message_handler, 0);
 
+local advertise_extended = archive.caps and archive.caps.full_id_range and archive.caps.ids;
+
 module:hook("account-disco-info", function(event)
 	(event.reply or event.stanza):tag("feature", {var=xmlns_mam}):up();
+	if advertise_extended then
+		(event.reply or event.stanza):tag("feature", {var=xmlns_mam_ext}):up();
+	end
 	(event.reply or event.stanza):tag("feature", {var=xmlns_st_id}):up();
 end);
 
--- a/plugins/mod_message.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_message.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -22,6 +22,15 @@
 	if t == "error" then
 		return true; -- discard
 	elseif t == "groupchat" then
+		local node, host = jid_split(bare);
+		if user_exists(node, host) then
+			if module:fire_event("message/bare/groupchat", {
+				origin = origin, stanza = stanza;
+			}) then
+				return true;
+			end
+		end
+
 		origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
 	elseif t == "headline" then
 		if user and stanza.attr.to == bare then
@@ -49,8 +58,8 @@
 		local ok
 		if user_exists(node, host) then
 			ok = module:fire_event('message/offline/handle', {
-				username = node;
-				origin = origin,
+				username = node, -- username of the recipient of the offline message
+				origin = origin, -- the sender
 				stanza = stanza,
 			});
 		end
@@ -80,5 +89,3 @@
 
 	return process_to_bare(stanza.attr.to or (origin.username..'@'..origin.host), origin, stanza);
 end, -1);
-
-module:add_feature("msgoffline");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_mimicking.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,86 @@
+-- Prosody IM
+-- Copyright (C) 2012 Florian Zeitz
+-- Copyright (C) 2019 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local encodings = require "util.encodings";
+assert(encodings.confusable, "This module requires that Prosody be built with ICU");
+local skeleton = encodings.confusable.skeleton;
+
+local usage = require "util.prosodyctl".show_usage;
+local usermanager = require "core.usermanager";
+local storagemanager = require "core.storagemanager";
+
+local skeletons
+function module.load()
+	if module.host ~= "*" then
+		skeletons = module:open_store("skeletons");
+	end
+end
+
+module:hook("user-registered", function(user)
+	local skel = skeleton(user.username);
+	local ok, err = skeletons:set(skel, { username = user.username });
+	if not ok then
+		module:log("error", "Unable to store mimicry data (%q => %q): %s", user.username, skel, err);
+	end
+end);
+
+module:hook_global("user-deleted", function(user)
+	if user.host ~= module.host then return end
+	local skel = skeleton(user.username);
+	local ok, err = skeletons:set(skel, nil);
+	if not ok and err then
+		module:log("error", "Unable to clear mimicry data (%q): %s", skel, err);
+	end
+end);
+
+module:hook("user-registering", function(user)
+	local existing, err = skeletons:get(skeleton(user.username));
+	if existing then
+		module:log("debug", "Attempt to register username '%s' which could be confused with '%s'", user.username, existing.username);
+		user.allowed = false;
+	elseif err then
+		module:log("error", "Unable to check if new username '%s' can be confused with any existing user: %s", err);
+	end
+end);
+
+function module.command(arg)
+	if (arg[1] ~= "bootstrap" or not arg[2]) then
+		usage("mod_mimicking bootstrap <host>", "Initialize username mimicry database");
+		return;
+	end
+
+	local host = arg[2];
+
+	local host_session = prosody.hosts[host];
+	if not host_session then
+		return "No such host";
+	end
+
+	storagemanager.initialize_host(host);
+	usermanager.initialize_host(host);
+
+	skeletons = storagemanager.open(host, "skeletons");
+
+	local count = 0;
+	for user in usermanager.users(host) do
+		local skel = skeleton(user);
+		local existing, err = skeletons:get(skel);
+		if existing and existing.username ~= user then
+			module:log("warn", "Existing usernames '%s' and '%s' are confusable", existing.username, user);
+		elseif err then
+			module:log("error", "Error checking for existing mimicry data (%q = %q): %s", user, skel, err);
+		end
+		local ok, err = skeletons:set(skel, { username = user });
+		if ok then
+			count = count + 1;
+		elseif err then
+			module:log("error", "Unable to store mimicry data (%q => %q): %s", user, skel, err);
+		end
+	end
+	module:log("info", "%d usernames indexed", count);
+end
--- a/plugins/mod_muc_mam.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_muc_mam.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,14 +1,15 @@
 -- XEP-0313: Message Archive Management for Prosody MUC
--- Copyright (C) 2011-2017 Kim Alvefur
+-- Copyright (C) 2011-2021 Kim Alvefur
 --
 -- This file is MIT/X11 licensed.
 
 if module:get_host_type() ~= "component" then
-	module:log("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name);
+	module:log_status("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name);
 	return;
 end
 
 local xmlns_mam     = "urn:xmpp:mam:2";
+local xmlns_mam_ext = "urn:xmpp:mam:2#extended";
 local xmlns_delay   = "urn:xmpp:delay";
 local xmlns_forward = "urn:xmpp:forward:0";
 local xmlns_st_id   = "urn:xmpp:sid:0";
@@ -21,6 +22,7 @@
 local jid_split = require "util.jid".split;
 local jid_prep = require "util.jid".prep;
 local dataform = require "util.dataforms".new;
+local get_form_type = require "util.dataforms".get_type;
 
 local mod_muc = module:depends"muc";
 local get_room_from_jid = mod_muc.get_room_from_jid;
@@ -29,9 +31,11 @@
 local tostring = tostring;
 local time_now = os.time;
 local m_min = math.min;
-local timestamp, timestamp_parse, datestamp = import( "util.datetime", "datetime", "parse", "date");
+local timestamp, datestamp = import("util.datetime", "datetime", "date");
 local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
 
+local cleanup_after = module:get_option_string("muc_log_expires_after", "1w");
+
 local default_history_length = 20;
 local max_history_length = module:get_option_number("max_history_messages", math.huge);
 
@@ -49,6 +53,9 @@
 local archive_store = "muc_log";
 local archive = module:open_store(archive_store, "archive");
 
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000);
+local archive_truncate = math.floor(archive_item_limit * 0.99);
+
 if archive.name == "null" or not archive.find then
 	if not archive.find then
 		module:log("error", "Attempt to open archive storage returned a driver without archive API support");
@@ -63,12 +70,15 @@
 
 local function archiving_enabled(room)
 	if log_all_rooms then
+		module:log("debug", "Archiving all rooms");
 		return true;
 	end
 	local enabled = room._data.archiving;
 	if enabled == nil then
+		module:log("debug", "Default is %s (for %s)", log_by_default, room.jid);
 		return log_by_default;
 	end
+	module:log("debug", "Logging in room %s is %s", room.jid, enabled);
 	return enabled;
 end
 
@@ -93,10 +103,10 @@
 
 -- Note: We ignore the 'with' field as this is internally used for stanza types
 local query_form = dataform {
-	{ name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; };
-	{ name = "with"; type = "jid-single"; };
-	{ name = "start"; type = "text-single" };
-	{ name = "end"; type = "text-single"; };
+	{ name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam };
+	{ name = "with"; type = "jid-single" };
+	{ name = "start"; type = "text-single"; datatype = "xs:dateTime" };
+	{ name = "end"; type = "text-single"; datatype = "xs:dateTime" };
 };
 
 -- Serve form
@@ -133,50 +143,64 @@
 
 	-- Search query parameters
 	local qstart, qend;
+	local qbefore, qafter;
+	local qids;
 	local form = query:get_child("x", "jabber:x:data");
 	if form then
-		local err;
+		local form_type, err = get_form_type(form);
+		if not form_type then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid dataform: "..err));
+			return true;
+		elseif form_type ~= xmlns_mam then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Unexpected FORM_TYPE, expected '"..xmlns_mam.."'"));
+			return true;
+		end
 		form, err = query_form:data(form);
 		if err then
 			origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err))));
 			return true;
 		end
 		qstart, qend = form["start"], form["end"];
+		qbefore, qafter = form["before-id"], form["after-id"];
+		qids = form["ids"];
 	end
 
-	if qstart or qend then -- Validate timestamps
-		local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend))
-		if (qstart and not vstart) or (qend and not vend) then
-			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp"))
-			return true;
-		end
-		qstart, qend = vstart, vend;
-	end
-
-	module:log("debug", "Archive query id %s from %s until %s)",
-		tostring(qid),
-		qstart and timestamp(qstart) or "the dawn of time",
-		qend and timestamp(qend) or "now");
-
 	-- RSM stuff
 	local qset = rsm.get(query);
 	local qmax = m_min(qset and qset.max or default_max_items, max_max_items);
 	local reverse = qset and qset.before or false;
 
-	local before, after = qset and qset.before, qset and qset.after;
+	local before, after = qset and qset.before or qbefore, qset and qset.after or qafter;
 	if type(before) ~= "string" then before = nil; end
+	-- A reverse query needs to be flipped
+	local flip = reverse;
+	-- A flip-page query needs to be the opposite of that.
+	if query:get_child("flip-page") then flip = not flip end
+
+	module:log("debug", "Archive query by %s id=%s when=%s...%s rsm=%q",
+		from,
+		qid or stanza.attr.id,
+		qstart and timestamp(qstart) or "",
+		qend and timestamp(qend) or "",
+		qset);
 
 	-- Load all the data!
 	local data, err = archive:find(room_node, {
 		start = qstart; ["end"] = qend; -- Time range
 		limit = qmax + 1;
 		before = before; after = after;
+		ids = qids;
 		reverse = reverse;
 		with = "message<groupchat";
 	});
 
 	if not data then
-		origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+		module:log("debug", "Archive query id=%s failed: %s", qid or stanza.attr.id, err);
+		if err == "item-not-found" then
+			origin.send(st.error_reply(stanza, "modify", "item-not-found"));
+		else
+			origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+		end
 		return true;
 	end
 	local total = tonumber(err);
@@ -219,27 +243,30 @@
 		if not first then first = id; end
 		last = id;
 
-		if reverse then
+		if flip then
 			results[count] = fwd_st;
 		else
 			origin.send(fwd_st);
 		end
 	end
 
-	if reverse then
+	if flip then
 		for i = #results, 1, -1 do
 			origin.send(results[i]);
 		end
+	end
+	if reverse then
 		first, last = last, first;
 	end
 
-	-- That's all folks!
-	module:log("debug", "Archive query %s completed", tostring(qid));
 
 	origin.send(st.reply(stanza)
-		:tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete })
+		:tag("fin", { xmlns = xmlns_mam, complete = complete })
 			:add_child(rsm.generate {
 				first = first, last = last, count = total }));
+
+	-- That's all folks!
+	module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1);
 	return true;
 end);
 
@@ -274,7 +301,7 @@
 	local data, err = archive:find(jid_split(room_jid), query);
 
 	if not data then
-		module:log("error", "Could not fetch history: %s", tostring(err));
+		module:log("error", "Could not fetch history: %s", err);
 		return
 	end
 
@@ -300,7 +327,7 @@
 			maxchars = maxchars - chars;
 		end
 		history[i], i = item, i+1;
-		-- module:log("debug", tostring(item));
+		-- module:log("debug", item);
 	end
 	function event.next_stanza()
 		i = i - 1;
@@ -325,7 +352,7 @@
 
 -- Handle messages
 local function save_to_history(self, stanza)
-	local room_node, room_host = jid_split(self.jid);
+	local room_node = jid_split(self.jid);
 
 	local stored_stanza = stanza;
 
@@ -352,7 +379,29 @@
 	end
 
 	-- And stash it
-	local id, err = archive:append(room_node, nil, stored_stanza, time_now(), with);
+	local time = time_now();
+	local id, err = archive:append(room_node, nil, stored_stanza, time, with);
+
+	if not id and err == "quota-limit" then
+		if type(cleanup_after) == "number" then
+			module:log("debug", "Room '%s' over quota, cleaning archive", room_node);
+			local cleaned = archive:delete(room_node, {
+				["end"] = (os.time() - cleanup_after);
+			});
+			if cleaned then
+				id, err = archive:append(room_node, nil, stored_stanza, time, with);
+			end
+		end
+		if not id and (archive.caps and archive.caps.truncate) then
+			module:log("debug", "Room '%s' over quota, truncating archive", room_node);
+			local truncated = archive:delete(room_node, {
+				truncate = archive_truncate;
+			});
+			if truncated then
+				id, err = archive:append(room_node, nil, stored_stanza, time, with);
+			end
+		end
+	end
 
 	if id then
 		schedule_cleanup(room_node);
@@ -390,16 +439,20 @@
 
 module:add_feature(xmlns_mam);
 
+local advertise_extended = archive.caps and archive.caps.full_id_range and archive.caps.ids;
+
 module:hook("muc-disco#info", function(event)
-	event.reply:tag("feature", {var=xmlns_mam}):up();
+	if archiving_enabled(event.room) then
+		event.reply:tag("feature", {var=xmlns_mam}):up();
+		if advertise_extended then
+			(event.reply or event.stanza):tag("feature", {var=xmlns_mam_ext}):up();
+		end
+	end
 	event.reply:tag("feature", {var=xmlns_st_id}):up();
 end);
 
 -- Cleanup
 
-local cleanup_after = module:get_option_string("muc_log_expires_after", "1w");
-local cleanup_interval = module:get_option_number("muc_log_cleanup_interval", 4 * 60 * 60);
-
 if cleanup_after ~= "never" then
 	local cleanup_storage = module:open_store("muc_log_cleanup");
 	local cleanup_map = module:open_store("muc_log_cleanup", "map");
@@ -426,17 +479,40 @@
 	-- outside the cleanup range.
 
 	local last_date = require "util.cache".new(module:get_option_number("muc_log_cleanup_date_cache_size", 1000));
-	function schedule_cleanup(roomname, date)
-		date = date or datestamp();
-		if last_date:get(roomname) == date then return end
-		local ok = cleanup_map:set(date, roomname, true);
-		if ok then
-			last_date:set(roomname, date);
+	if not ( archive.caps and archive.caps.wildcard_delete ) then
+		function schedule_cleanup(roomname, date)
+			date = date or datestamp();
+			if last_date:get(roomname) == date then return end
+			local ok = cleanup_map:set(date, roomname, true);
+			if ok then
+				last_date:set(roomname, date);
+			end
 		end
 	end
 
+	local cleanup_time = module:measure("cleanup", "times");
+
 	local async = require "util.async";
-	cleanup_runner = async.runner(function ()
+	module:daily("Remove expired messages", function ()
+		local cleanup_done = cleanup_time();
+
+		if archive.caps and archive.caps.wildcard_delete then
+			local ok, err = archive:delete(true, { ["end"] = os.time() - cleanup_after })
+			if ok then
+				local sum = tonumber(ok);
+				if sum then
+					module:log("info", "Deleted %d expired messages", sum);
+				else
+					-- driver did not tell
+					module:log("info", "Deleted all expired messages");
+				end
+			else
+				module:log("error", "Could not delete messages: %s", err);
+			end
+			cleanup_done();
+			return;
+		end
+
 		local rooms = {};
 		local cut_off = datestamp(os.time() - cleanup_after);
 		for date in cleanup_storage:users() do
@@ -470,12 +546,9 @@
 			wait();
 		end
 		module:log("info", "Deleted %d expired messages for %d rooms", sum, num_rooms);
+		cleanup_done();
 	end);
 
-	cleanup_task = module:add_timer(1, function ()
-		cleanup_runner:run(true);
-		return cleanup_interval;
-	end);
 else
 	module:log("debug", "Archive expiry disabled");
 end
--- a/plugins/mod_net_multiplex.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_net_multiplex.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,22 +1,38 @@
 module:set_global();
 
+local array = require "util.array";
 local max_buffer_len = module:get_option_number("multiplex_buffer_size", 1024);
+local default_mode = module:get_option_number("network_default_read_size", 4096);
 
 local portmanager = require "core.portmanager";
 
 local available_services = {};
+local service_by_protocol = {};
+local available_protocols = array();
 
 local function add_service(service)
 	local multiplex_pattern = service.multiplex and service.multiplex.pattern;
+	local protocol_name = service.multiplex and service.multiplex.protocol;
+	if protocol_name then
+		module:log("debug", "Adding multiplex service %q with protocol %q", service.name, protocol_name);
+		service_by_protocol[protocol_name] = service;
+		available_protocols:push(protocol_name);
+	end
 	if multiplex_pattern then
 		module:log("debug", "Adding multiplex service %q with pattern %q", service.name, multiplex_pattern);
 		available_services[service] = multiplex_pattern;
-	else
+	elseif not protocol_name then
 		module:log("debug", "Service %q is not multiplex-capable", service.name);
 	end
 end
 module:hook("service-added", function (event) add_service(event.service); end);
-module:hook("service-removed", function (event)	available_services[event.service] = nil; end);
+module:hook("service-removed", function (event)
+	available_services[event.service] = nil;
+	if event.service.multiplex and event.service.multiplex.protocol then
+		available_protocols:filter(function (p) return p ~= event.service.multiplex.protocol end);
+		service_by_protocol[event.service.multiplex.protocol] = nil;
+	end
+end);
 
 for _, services in pairs(portmanager.get_registered_services()) do
 	for _, service in ipairs(services) do
@@ -26,9 +42,22 @@
 
 local buffers = {};
 
-local listener = { default_mode = "*a" };
+local listener = { default_mode = max_buffer_len };
 
-function listener.onconnect()
+function listener.onconnect(conn)
+	local sock = conn:socket();
+	if sock.getalpn then
+		local selected_proto = sock:getalpn();
+		local service = service_by_protocol[selected_proto];
+		if service then
+			module:log("debug", "Routing incoming connection to %s based on ALPN %q", service.name, selected_proto);
+			local next_listener = service.listener;
+			conn:setlistener(next_listener);
+			conn:set_mode(next_listener.default_mode or default_mode);
+			local onconnect = next_listener.onconnect;
+			if onconnect then return onconnect(conn) end
+		end
+	end
 end
 
 function listener.onincoming(conn, data)
@@ -40,6 +69,7 @@
 			module:log("debug", "Routing incoming connection to %s", service.name);
 			local next_listener = service.listener;
 			conn:setlistener(next_listener);
+			conn:set_mode(next_listener.default_mode or default_mode);
 			local onconnect = next_listener.onconnect;
 			if onconnect then onconnect(conn) end
 			return next_listener.onincoming(conn, buf);
@@ -68,5 +98,10 @@
 	name = "multiplex_ssl";
 	config_prefix = "ssl";
 	encryption = "ssl";
+	ssl_config = {
+		alpn = function ()
+			return available_protocols;
+		end;
+	};
 	listener = listener;
 });
--- a/plugins/mod_offline.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_offline.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -24,11 +24,16 @@
 		node = origin.username;
 	end
 
-	return offline_messages:append(node, nil, stanza, os.time(), "");
+	local ok = offline_messages:append(node, nil, stanza, os.time(), "");
+	if ok then
+		module:log("debug", "Saved to offline storage: %s", stanza:top_tag());
+	end
+	return ok;
 end, -1);
 
 module:hook("message/offline/broadcast", function(event)
 	local origin = event.origin;
+	origin.log("debug", "Broadcasting offline messages");
 
 	local node, host = origin.username, origin.host;
 
@@ -38,6 +43,9 @@
 		stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = host, stamp = datetime.datetime(when)}):up(); -- XEP-0203
 		origin.send(stanza);
 	end
-	offline_messages:delete(node);
+	local ok = offline_messages:delete(node);
+	if type(ok) == "number" and ok > 0 then
+		origin.log("debug", "%d offline messages consumed");
+	end
 	return true;
 end, -1);
--- a/plugins/mod_pep.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_pep.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,6 +8,7 @@
 local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
 local cache = require "util.cache";
 local set = require "util.set";
+local new_id = require "util.id".medium;
 local storagemanager = require "core.storagemanager";
 local usermanager = require "core.usermanager";
 
@@ -50,6 +51,20 @@
 
 local max_max_items = module:get_option_number("pep_max_items", 256);
 
+local function tonumber_max_items(n)
+	if n == "max" then
+		return max_max_items;
+	end
+	return tonumber(n);
+end
+
+for _, field in ipairs(lib_pubsub.node_config_form) do
+	if field.var == "pubsub#max_items" then
+		field.range_max = max_max_items;
+		break;
+	end
+end
+
 function module.save()
 	return {
 		recipients = recipients;
@@ -65,7 +80,7 @@
 end
 
 function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor
-	if (new_config["max_items"] or 1) > max_max_items then
+	if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then
 		return false;
 	end
 	if new_config["access_model"] ~= "presence"
@@ -111,14 +126,10 @@
 local function simple_itemstore(username)
 	local driver = storagemanager.get_driver(module.host, "pep_data");
 	return function (config, node)
-		if config["persist_items"] then
-			module:log("debug", "Creating new persistent item store for user %s, node %q", username, node);
-			local archive = driver:open("pep_"..node, "archive");
-			return lib_pubsub.archive_itemstore(archive, config, username, node, false);
-		else
-			module:log("debug", "Creating new ephemeral item store for user %s, node %q", username, node);
-			return cache.new(tonumber(config["max_items"]));
-		end
+		local max_items = tonumber_max_items(config["max_items"]);
+		module:log("debug", "Creating new persistent item store for user %s, node %q", username, node);
+		local archive = driver:open("pep_"..node, "archive");
+		return lib_pubsub.archive_itemstore(archive, max_items, username, node, false);
 	end
 end
 
@@ -133,9 +144,6 @@
 		if kind == "retract" then
 			kind = "items"; -- XEP-0060 signals retraction in an <items> container
 		end
-		local message = st.message({ from = user_bare, type = "headline" })
-			:tag("event", { xmlns = xmlns_pubsub_event })
-				:tag(kind, { node = node });
 		if item then
 			item = st.clone(item);
 			item.attr.xmlns = nil; -- Clear the pubsub namespace
@@ -144,10 +152,19 @@
 					item:maptags(function () return nil; end);
 				end
 			end
+		end
+
+		local id = new_id();
+		local message = st.message({ from = user_bare, type = "headline", id = id })
+			:tag("event", { xmlns = xmlns_pubsub_event })
+				:tag(kind, { node = node });
+
+		if item then
 			message:add_child(item);
 		end
+
 		for jid in pairs(jids) do
-			module:log("debug", "Sending notification to %s from %s: %s", jid, user_bare, tostring(item));
+			module:log("debug", "Sending notification to %s from %s for node %s", jid, user_bare, node);
 			message.attr.to = jid;
 			module:send(message);
 		end
@@ -175,19 +192,23 @@
 	end
 end
 
+-- Read-only service with no nodes where nobody is allowed anything to act as a
+-- fallback for interactions with non-existent users
 local nobody_service = pubsub.new({
-	service = pubsub.new({
-		node_defaults = {
-			["max_items"] = 1;
-			["persist_items"] = false;
-			["access_model"] = "presence";
-			["send_last_published_item"] = "on_sub_and_presence";
-		};
-	});
+	node_defaults = {
+		["max_items"] = 1;
+		["persist_items"] = false;
+		["access_model"] = "presence";
+		["send_last_published_item"] = "on_sub_and_presence";
+	};
+	autocreate_on_publish = false;
+	autocreate_on_subscribe = false;
+	get_affiliation = function ()
+		return "outcast";
+	end;
 });
 
 function get_pep_service(username)
-	module:log("debug", "get_pep_service(%q)", username);
 	local user_bare = jid_join(username, host);
 	local service = services[username];
 	if service then
@@ -196,13 +217,16 @@
 	if not usermanager.user_exists(username, host) then
 		return nobody_service;
 	end
+	module:log("debug", "Creating pubsub service for user %q", username);
 	service = pubsub.new({
 		pep_username = username;
 		node_defaults = {
 			["max_items"] = 1;
 			["persist_items"] = true;
 			["access_model"] = "presence";
+			["send_last_published_item"] = "on_sub_and_presence";
 		};
+		max_items = max_max_items;
 
 		autocreate_on_publish = true;
 		autocreate_on_subscribe = false;
@@ -227,6 +251,7 @@
 			end;
 		};
 
+		jid = user_bare;
 		normalize_jid = jid_bare;
 
 		check_node_config = check_node_config;
@@ -252,8 +277,6 @@
 module:hook("iq/bare/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
 module:hook("iq/bare/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
 
-module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody"));
-module:add_feature("http://jabber.org/protocol/pubsub#publish");
 
 local function get_caps_hash_from_presence(stanza, current)
 	local t = stanza.attr.type;
@@ -279,6 +302,8 @@
 end
 
 local function resend_last_item(jid, node, service)
+	local ok, config = service:get_node_config(node, true);
+	if ok and config.send_last_published_item ~= "on_sub_and_presence" then return end
 	local ok, id, item = service:get_last_item(node, jid);
 	if not (ok and id) then return; end
 	service.config.broadcaster("items", node, { [jid] = true }, item);
--- a/plugins/mod_pep_simple.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_pep_simple.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -14,6 +14,7 @@
 local pairs = pairs;
 local next = next;
 local type = type;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
 local calculate_hash = require "util.caps".calculate_hash;
 local core_post_stanza = prosody.core_post_stanza;
 local bare_sessions = prosody.bare_sessions;
@@ -84,6 +85,7 @@
 	if d and notify then
 		for node in pairs(notify) do
 			if d[node] then
+				-- luacheck: ignore id
 				local id, item = unpack(d[node]);
 				session.send(st.message({from=user, to=recipient, type='headline'})
 					:tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'})
@@ -229,13 +231,13 @@
 				return true;
 			else --invalid request
 				session.send(st.error_reply(stanza, 'modify', 'bad-request'));
-				module:log("debug", "Invalid request: %s", tostring(payload));
+				module:log("debug", "Invalid request: %s", payload);
 				return true;
 			end
 		else --no presence subscription
 			session.send(st.error_reply(stanza, 'auth', 'not-authorized')
 				:tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'}));
-			module:log("debug", "Unauthorized request: %s", tostring(payload));
+			module:log("debug", "Unauthorized request: %s", payload);
 			return true;
 		end
 	end
--- a/plugins/mod_ping.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_ping.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -11,23 +11,9 @@
 module:add_feature("urn:xmpp:ping");
 
 local function ping_handler(event)
-	return event.origin.send(st.reply(event.stanza));
+	event.origin.send(st.reply(event.stanza));
+	return true;
 end
 
 module:hook("iq-get/bare/urn:xmpp:ping:ping", ping_handler);
 module:hook("iq-get/host/urn:xmpp:ping:ping", ping_handler);
-
--- Ad-hoc command
-
-local datetime = require "util.datetime".datetime;
-
-function ping_command_handler (self, data, state) -- luacheck: ignore 212
-	local now = datetime();
-	return { info = "Pong\n"..now, status = "completed" };
-end
-
-module:depends "adhoc";
-local adhoc_new = module:require "adhoc".new;
-local descriptor = adhoc_new("Ping", "ping", ping_command_handler);
-module:provides("adhoc", descriptor);
-
--- a/plugins/mod_posix.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_posix.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -20,7 +20,6 @@
 	module:log("warn", "Couldn't load signal library, won't respond to SIGTERM");
 end
 
-local format = require "util.format".format;
 local lfs = require "lfs";
 local stat = lfs.attributes;
 
@@ -31,39 +30,12 @@
 local umask = module:get_option_string("umask", "027");
 pposix.umask(umask);
 
--- Allow switching away from root, some people like strange ports.
-module:hook("server-started", function ()
-	local uid = module:get_option("setuid");
-	local gid = module:get_option("setgid");
-	if gid then
-		local success, msg = pposix.setgid(gid);
-		if success then
-			module:log("debug", "Changed group to %s successfully.", gid);
-		else
-			module:log("error", "Failed to change group to %s. Error: %s", gid, msg);
-			prosody.shutdown("Failed to change group to %s", gid);
-		end
-	end
-	if uid then
-		local success, msg = pposix.setuid(uid);
-		if success then
-			module:log("debug", "Changed user to %s successfully.", uid);
-		else
-			module:log("error", "Failed to change user to %s. Error: %s", uid, msg);
-			prosody.shutdown("Failed to change user to %s", uid);
-		end
-	end
-end);
-
 -- Don't even think about it!
 if not prosody.start_time then -- server-starting
-	local suid = module:get_option("setuid");
-	if not suid or suid == 0 or suid == "root" then
-		if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then
-			module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
-			module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root");
-			prosody.shutdown("Refusing to run as root");
-		end
+	if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then
+		module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
+		module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root");
+		prosody.shutdown("Refusing to run as root", 1);
 	end
 end
 
@@ -89,19 +61,19 @@
 		pidfile_handle, err = io.open(pidfile, mode);
 		if not pidfile_handle then
 			module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
-			prosody.shutdown("Couldn't write pidfile");
+			prosody.shutdown("Couldn't write pidfile", 1);
 		else
 			if not lfs.lock(pidfile_handle, "w") then -- Exclusive lock
 				local other_pid = pidfile_handle:read("*a");
 				module:log("error", "Another Prosody instance seems to be running with PID %s, quitting", other_pid);
 				pidfile_handle = nil;
-				prosody.shutdown("Prosody already running");
+				prosody.shutdown("Prosody already running", 1);
 			else
 				pidfile_handle:close();
 				pidfile_handle, err = io.open(pidfile, "w+");
 				if not pidfile_handle then
 					module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
-					prosody.shutdown("Couldn't write pidfile");
+					prosody.shutdown("Couldn't write pidfile", 1);
 				else
 					if lfs.lock(pidfile_handle, "w") then
 						pidfile_handle:write(tostring(pposix.getpid()));
@@ -113,24 +85,15 @@
 	end
 end
 
-local syslog_opened;
-function syslog_sink_maker(config) -- luacheck: ignore 212/config
-	if not syslog_opened then
-		pposix.syslog_open("prosody", module:get_option_string("syslog_facility"));
-		syslog_opened = true;
-	end
-	local syslog = pposix.syslog_log;
-	return function (name, level, message, ...)
-		syslog(level, name, format(message, ...));
-	end;
-end
-require "core.loggingmanager".register_sink_type("syslog", syslog_sink_maker);
-
 local daemonize = prosody.opts.daemonize;
 
 if daemonize == nil then
 	-- Fall back to config file if not specified on command-line
-	daemonize = module:get_option("daemonize", prosody.installed);
+	daemonize = module:get_option_boolean("daemonize", nil);
+	if daemonize ~= nil then
+		module:log("warn", "The 'daemonize' option has been deprecated, specify -D or -F on the command line instead.");
+		-- TODO: Write some docs and include a link in the warning.
+	end
 end
 
 local function remove_log_sinks()
@@ -154,9 +117,7 @@
 			write_pidfile();
 		end
 	end
-	if not prosody.start_time then -- server-starting
-		daemonize_server();
-	end
+	module:hook("server-started", daemonize_server)
 else
 	-- Not going to daemonize, so write the pid of this process
 	write_pidfile();
@@ -169,22 +130,43 @@
 	module:add_timer(0, function ()
 		signal.signal("SIGTERM", function ()
 			module:log("warn", "Received SIGTERM");
-			prosody.unlock_globals();
-			prosody.shutdown("Received SIGTERM");
-			prosody.lock_globals();
+			prosody.main_thread:run(function ()
+				prosody.unlock_globals();
+				prosody.shutdown("Received SIGTERM");
+				prosody.lock_globals();
+			end);
 		end);
 
 		signal.signal("SIGHUP", function ()
 			module:log("info", "Received SIGHUP");
-			prosody.reload_config();
+			prosody.main_thread:run(function ()
+				prosody.reload_config();
+			end);
 			-- this also reloads logging
 		end);
 
 		signal.signal("SIGINT", function ()
 			module:log("info", "Received SIGINT");
-			prosody.unlock_globals();
-			prosody.shutdown("Received SIGINT");
-			prosody.lock_globals();
+			prosody.main_thread:run(function ()
+				prosody.unlock_globals();
+				prosody.shutdown("Received SIGINT");
+				prosody.lock_globals();
+			end);
+		end);
+
+		signal.signal("SIGUSR1", function ()
+			module:log("info", "Received SIGUSR1");
+			module:fire_event("signal/SIGUSR1");
+		end);
+
+		signal.signal("SIGUSR2", function ()
+			module:log("info", "Received SIGUSR2");
+			module:fire_event("signal/SIGUSR2");
 		end);
 	end);
 end
+
+-- For other modules to reference
+features = {
+	signal_events = true;
+};
--- a/plugins/mod_presence.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_presence.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -14,6 +14,7 @@
 local tonumber = tonumber;
 
 local core_post_stanza = prosody.core_post_stanza;
+local core_process_stanza = prosody.core_process_stanza;
 local st = require "util.stanza";
 local jid_split = require "util.jid".split;
 local jid_bare = require "util.jid".bare;
@@ -30,6 +31,14 @@
 
 local ignore_presence_priority = module:get_option_boolean("ignore_presence_priority", false);
 
+local pre_approval_stream_feature = st.stanza("sub", {xmlns="urn:xmpp:features:pre-approval"});
+module:hook("stream-features", function(event)
+	local origin, features = event.origin, event.features;
+	if origin.username then
+		features:add_child(pre_approval_stream_feature);
+	end
+end);
+
 function handle_normal_presence(origin, stanza)
 	if ignore_presence_priority then
 		local priority = stanza:get_child("priority");
@@ -81,8 +90,14 @@
 				res.presence.attr.to = nil;
 			end
 		end
-		for jid in pairs(roster[false].pending) do -- resend incoming subscription requests
-			origin.send(st.presence({type="subscribe", from=jid})); -- TODO add to attribute? Use original?
+		for jid, pending_request in pairs(roster[false].pending) do -- resend incoming subscription requests
+			if type(pending_request) == "table" then
+				local subscribe = st.deserialize(pending_request);
+				subscribe.attr.type, subscribe.attr.from = "subscribe", jid;
+				origin.send(subscribe);
+			else
+				origin.send(st.presence({type="subscribe", from=jid}));
+			end
 		end
 		local request = st.presence({type="subscribe", from=origin.username.."@"..origin.host});
 		for jid, item in pairs(roster) do -- resend outgoing subscription requests
@@ -175,8 +190,10 @@
 		if rostermanager.subscribed(node, host, to_bare) then
 			rostermanager.roster_push(node, host, to_bare);
 		end
-		core_post_stanza(origin, stanza);
-		send_presence_of_available_resources(node, host, to_bare, origin);
+		if rostermanager.is_contact_subscribed(node, host, to_bare) then
+			core_post_stanza(origin, stanza);
+			send_presence_of_available_resources(node, host, to_bare, origin);
+		end
 		if rostermanager.is_user_subscribed(node, host, to_bare) then
 			core_post_stanza(origin, st.presence({ type = "probe", from = from_bare, to = to_bare }));
 		end
@@ -184,6 +201,8 @@
 		-- 1. send unavailable
 		-- 2. route stanza
 		-- 3. roster push (subscription = from or both)
+		-- luacheck: ignore 211/pending_in
+		-- Is pending_in meant to be used?
 		local success, pending_in, subscribed = rostermanager.unsubscribed(node, host, to_bare);
 		if success then
 			if subscribed then
@@ -223,10 +242,16 @@
 			if 0 == send_presence_of_available_resources(node, host, from_bare, origin) then
 				core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- TODO send last activity
 			end
+		elseif rostermanager.is_contact_preapproved(node, host, from_bare) then
+			if not rostermanager.is_contact_pending_in(node, host, from_bare) then
+				if rostermanager.set_contact_pending_in(node, host, from_bare, stanza) then
+					core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="subscribed"}), true);
+				end -- TODO else return error, unable to save
+			end
 		else
 			core_post_stanza(hosts[host], st.presence({from=to_bare, to=from_bare, type="unavailable"}), true); -- acknowledging receipt
 			if not rostermanager.is_contact_pending_in(node, host, from_bare) then
-				if rostermanager.set_contact_pending_in(node, host, from_bare) then
+				if rostermanager.set_contact_pending_in(node, host, from_bare, stanza) then
 					sessionmanager.send_to_available_resources(node, host, stanza);
 				end -- TODO else return error, unable to save
 			end
@@ -346,7 +371,7 @@
 		if err then
 			pres:tag("status"):text("Disconnected: "..err):up();
 		end
-		session:dispatch_stanza(pres);
+		core_process_stanza(session, pres);
 	elseif session.directed then
 		local pres = st.presence{ type = "unavailable", from = session.full_jid };
 		if err then
--- a/plugins/mod_proxy65.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_proxy65.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,7 +12,6 @@
 local jid_compare, jid_prep = require "util.jid".compare, require "util.jid".prep;
 local st = require "util.stanza";
 local sha1 = require "util.hashes".sha1;
-local b64 = require "util.encodings".base64.encode;
 local server = require "net.server";
 local portmanager = require "core.portmanager";
 
@@ -45,7 +44,7 @@
 		end -- else error, unexpected input
 		conn:write("\5\255"); -- send (SOCKS version 5, no acceptable method)
 		conn:close();
-		module:log("debug", "Invalid SOCKS5 greeting received: '%s'", b64(data));
+		module:log("debug", "Invalid SOCKS5 greeting received: %q", data:sub(1, 300));
 	else -- connection request
 		--local head = string.char( 0x05, 0x01, 0x00, 0x03, 40 ); -- ( VER=5=SOCKS5, CMD=1=CONNECT, RSV=0=RESERVED, ATYP=3=DOMAIMNAME, SHA-1 size )
 		if #data == 47 and data:sub(1,5) == "\5\1\0\3\40" and data:sub(-2) == "\0\0" then
@@ -67,7 +66,7 @@
 		else -- error, unexpected input
 			conn:write("\5\1\0\3\0\0\0"); -- VER, REP, RSV, ATYP, BND.ADDR (sha), BND.PORT (2 Byte)
 			conn:close();
-			module:log("debug", "Invalid SOCKS5 negotiation received: '%s'", b64(data));
+			module:log("debug", "Invalid SOCKS5 negotiation received: %q", data:sub(1, 300));
 		end
 	end
 end
@@ -125,7 +124,7 @@
 		end
 
 		if not allow then
-			module:log("warn", "Denying use of proxy for %s", tostring(stanza.attr.from));
+			module:log("warn", "Denying use of proxy for %s", stanza.attr.from);
 			origin.send(st.error_reply(stanza, "auth", "forbidden"));
 			return true;
 		end
--- a/plugins/mod_pubsub/mod_pubsub.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -4,6 +4,7 @@
 local usermanager = require "core.usermanager";
 local new_id = require "util.id".medium;
 local storagemanager = require "core.storagemanager";
+local xtemplate = require "util.xtemplate";
 
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event";
@@ -39,16 +40,32 @@
 --   get(node_name)
 --   users(): iterator over (node_name)
 
+local max_max_items = module:get_option_number("pubsub_max_items", 256);
+
+local function tonumber_max_items(n)
+	if n == "max" then
+		return max_max_items;
+	end
+	return tonumber(n);
+end
+
+for _, field in ipairs(lib_pubsub.node_config_form) do
+	if field.var == "pubsub#max_items" then
+		field.range_max = max_max_items;
+		break;
+	end
+end
 
 local node_store = module:open_store(module.name.."_nodes");
 
-local function create_simple_itemstore(node_config, node_name)
+local function create_simple_itemstore(node_config, node_name) --> util.cache like object
 	local driver = storagemanager.get_driver(module.host, "pubsub_data");
 	local archive = driver:open("pubsub_"..node_name, "archive");
-	return lib_pubsub.archive_itemstore(archive, node_config, nil, node_name);
+	local max_items = tonumber_max_items(node_config["max_items"]);
+	return lib_pubsub.archive_itemstore(archive, max_items, nil, node_name);
 end
 
-function simple_broadcast(kind, node, jids, item, actor, node_obj)
+function simple_broadcast(kind, node, jids, item, actor, node_obj, service) --luacheck: ignore 431/service
 	if node_obj then
 		if node_obj.config["notify_"..kind] == false then
 			return;
@@ -65,8 +82,10 @@
 			if node_obj and node_obj.config.include_payload == false then
 				item:maptags(function () return nil; end);
 			end
-			if expose_publisher and actor then
-				item.attr.publisher = actor
+			if not expose_publisher then
+				item.attr.publisher = nil;
+			elseif not item.attr.publisher and actor ~= true then
+				item.attr.publisher = service.config.normalize_jid(actor);
 			end
 		end
 	end
@@ -75,17 +94,17 @@
 	local msg_type = node_obj and node_obj.config.notification_type or "headline";
 	local message = st.message({ from = module.host, type = msg_type, id = id })
 		:tag("event", { xmlns = xmlns_pubsub_event })
-			:tag(kind, { node = node })
+			:tag(kind, { node = node });
 
 	if item then
 		message:add_child(item);
 	end
 
 	local summary;
-	-- Compose a sensible textual representation of at least Atom payloads
 	if item and item.tags[1] then
 		local payload = item.tags[1];
-		summary = module:fire_event("pubsub-summary/"..payload.attr.xmlns, {
+		local payload_type = node_obj and node_obj.config.payload_type or payload.attr.xmlns;
+		summary = module:fire_event("pubsub-summary/"..payload_type, {
 			kind = kind, node = node, jids = jids, actor = actor, item = item, payload = payload,
 		});
 	end
@@ -100,12 +119,12 @@
 	end
 end
 
-local max_max_items = module:get_option_number("pubsub_max_items", 256);
-function check_node_config(node, actor, new_config) -- luacheck: ignore 212/actor 212/node
-	if (new_config["max_items"] or 1) > max_max_items then
+function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor
+	if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then
 		return false;
 	end
-	if new_config["access_model"] ~= "whitelist" and new_config["access_model"] ~= "open" then
+	if new_config["access_model"] ~= "whitelist"
+	and new_config["access_model"] ~= "open" then
 		return false;
 	end
 	return true;
@@ -115,19 +134,18 @@
 	return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item" and #item.tags == 1;
 end
 
-module:hook("pubsub-summary/http://www.w3.org/2005/Atom", function (event)
-	local payload = event.payload;
-	local title = payload:get_child_text("title");
-	local summary = payload:get_child_text("summary");
-	if not summary and title then
-		local author = payload:find("author/name#");
-		summary = title;
-		if author then
-			summary = author .. " posted " .. summary;
-		end
-	end
-	return summary;
-end, -1);
+-- Compose a textual representation of Atom payloads
+local summary_templates = module:get_option("pubsub_summary_templates", {
+	["http://www.w3.org/2005/Atom"] = "{summary|or{{author/name|and{{author/name} posted }}{title}}}";
+})
+
+for pubsub_type, template in pairs(summary_templates) do
+	module:hook("pubsub-summary/"..pubsub_type, function (event)
+		local payload = event.payload;
+		return xtemplate.render(template, payload, tostring);
+	end, -1);
+end
+
 
 module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
 module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
@@ -172,6 +190,17 @@
 
 function set_service(new_service)
 	service = new_service;
+	service.config.autocreate_on_publish = autocreate_on_publish;
+	service.config.autocreate_on_subscribe = autocreate_on_subscribe;
+	service.config.expose_publisher = expose_publisher;
+
+	service.config.nodestore = node_store;
+	service.config.itemstore = create_simple_itemstore;
+	service.config.broadcaster = simple_broadcast;
+	service.config.itemcheck = is_item_stanza;
+	service.config.check_node_config = check_node_config;
+	service.config.get_affiliation = get_affiliation;
+
 	module.environment.service = service;
 	add_disco_features_from_service(service);
 end
@@ -190,7 +219,12 @@
 	set_service(pubsub.new({
 		autocreate_on_publish = autocreate_on_publish;
 		autocreate_on_subscribe = autocreate_on_subscribe;
+		expose_publisher = expose_publisher;
 
+		node_defaults = {
+			["persist_items"] = true;
+		};
+		max_items = max_max_items;
 		nodestore = node_store;
 		itemstore = create_simple_itemstore;
 		broadcaster = simple_broadcast;
@@ -198,6 +232,7 @@
 		check_node_config = check_node_config;
 		get_affiliation = get_affiliation;
 
+		jid = module.host;
 		normalize_jid = jid_bare;
 	}));
 end
--- a/plugins/mod_pubsub/pubsub.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_pubsub/pubsub.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,6 +7,7 @@
 local it = require "util.iterators";
 local uuid_generate = require "util.uuid".generate;
 local dataform = require"util.dataforms".new;
+local errors = require "util.error";
 
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors";
@@ -31,9 +32,13 @@
 	["internal-server-error"] = { "wait", "internal-server-error" };
 	["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" };
 	["invalid-item"] = { "modify", "bad-request", "invalid item" };
+	["persistent-items-unsupported"] = { "cancel", "feature-not-implemented", nil, "persistent-items" };
 };
 local function pubsub_error_reply(stanza, error)
 	local e = pubsub_errors[error];
+	if not e and errors.is_err(error) then
+		e = { error.type, error.condition, error.text, error.pubsub_condition };
+	end
 	local reply = st.error_reply(stanza, t_unpack(e, 1, 3));
 	if e[4] then
 		reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up();
@@ -79,8 +84,9 @@
 	};
 	{
 		type = "text-single";
-		datatype = "xs:integer";
+		datatype = "pubsub:integer-or-max";
 		name = "max_items";
+		range_min = 1;
 		var = "pubsub#max_items";
 		label = "Max # of items to persist";
 	};
@@ -115,6 +121,12 @@
 		};
 	};
 	{
+		type = "list-single";
+		var = "pubsub#send_last_published_item";
+		name = "send_last_published_item";
+		options = { "never"; "on_sub"; "on_sub_and_presence" };
+	};
+	{
 		type = "boolean";
 		value = true;
 		label = "Whether to deliver event notifications";
@@ -153,6 +165,7 @@
 		value = true;
 	};
 };
+_M.node_config_form = node_config_form;
 
 local subscribe_options_form = dataform {
 	{
@@ -166,6 +179,7 @@
 		label = "Receive message body in addition to payload?";
 	};
 };
+_M.subscribe_options_form = subscribe_options_form;
 
 local node_metadata_form = dataform {
 	{
@@ -185,7 +199,16 @@
 		type = "text-single";
 		name = "pubsub#type";
 	};
+	{
+		type = "text-single";
+		name = "pubsub#access_model";
+	};
+	{
+		type = "text-single";
+		name = "pubsub#publish_model";
+	};
 };
+_M.node_metadata_form = node_metadata_form;
 
 local service_method_feature_map = {
 	add_subscription = { "subscribe", "subscription-options" };
@@ -237,19 +260,32 @@
 		supported_features:add("access-"..service.node_defaults.access_model);
 	end
 
+	if service.node_defaults.send_last_published_item ~= "never" then
+		supported_features:add("last-published");
+	end
+
 	if rawget(service.config, "itemstore") and rawget(service.config, "nodestore") then
 		supported_features:add("persistent-items");
 	end
 
+	if true --[[ node_metadata_form[max_items].datatype == "pubsub:integer-or-max" ]] then
+		supported_features:add("config-node-max");
+	end
+
 	return supported_features;
 end
 
 function _M.handle_disco_info_node(event, service)
 	local stanza, reply, node = event.stanza, event.reply, event.node;
 	local ok, ret = service:get_nodes(stanza.attr.from);
+	if not ok then
+		event.origin.send(pubsub_error_reply(stanza, ret));
+		return true;
+	end
 	local node_obj = ret[node];
-	if not ok or not node_obj then
-		return;
+	if not node_obj then
+		event.origin.send(pubsub_error_reply(stanza, "item-not-found"));
+		return true;
 	end
 	event.exists = true;
 	reply:tag("identity", { category = "pubsub", type = "leaf" }):up();
@@ -258,6 +294,8 @@
 			["pubsub#title"] = node_obj.config.title;
 			["pubsub#description"] = node_obj.config.description;
 			["pubsub#type"] = node_obj.config.payload_type;
+			["pubsub#access_model"] = node_obj.config.access_model;
+			["pubsub#publish_model"] = node_obj.config.publish_model;
 		}, "result"));
 	end
 end
@@ -266,11 +304,12 @@
 	local stanza, reply, node = event.stanza, event.reply, event.node;
 	local ok, ret = service:get_items(node, stanza.attr.from);
 	if not ok then
-		return;
+		event.origin.send(pubsub_error_reply(stanza, ret));
+		return true;
 	end
 
 	for _, id in ipairs(ret) do
-		reply:tag("item", { jid = module.host, name = id }):up();
+		reply:tag("item", { jid = service.jid or module.host, name = id }):up();
 	end
 	event.exists = true;
 end
@@ -308,24 +347,36 @@
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
-	local ok, results = service:get_items(node, stanza.attr.from, requested_items);
+	local resultspec; -- TODO rsm.get()
+	if items.attr.max_items then
+		resultspec = { max = tonumber(items.attr.max_items) };
+	end
+	local ok, results = service:get_items(node, stanza.attr.from, requested_items, resultspec);
 	if not ok then
 		origin.send(pubsub_error_reply(stanza, results));
 		return true;
 	end
 
+	local expose_publisher = service.config.expose_publisher;
+
 	local data = st.stanza("items", { node = node });
-	for _, id in ipairs(results) do
-		data:add_child(results[id]);
+	local iter, v, i = ipairs(results);
+	if not requested_items then
+		-- XXX Hack to preserve order of explicitly requested items.
+		iter, v, i = it.reverse(iter, v, i);
 	end
-	local reply;
-	if data then
-		reply = st.reply(stanza)
-			:tag("pubsub", { xmlns = xmlns_pubsub })
-				:add_child(data);
-	else
-		reply = pubsub_error_reply(stanza, "item-not-found");
+
+	for _, id in iter, v, i do
+		local item = results[id];
+		if not expose_publisher then
+			item = st.clone(item);
+			item.attr.publisher = nil;
+		end
+		data:add_child(item);
 	end
+	local reply = st.reply(stanza)
+		:tag("pubsub", { xmlns = xmlns_pubsub })
+			:add_child(data);
 	origin.send(reply);
 	return true;
 end
@@ -496,6 +547,12 @@
 		reply = pubsub_error_reply(stanza, ret);
 	end
 	origin.send(reply);
+	local ok, config = service:get_node_config(node, true);
+	if ok and config.send_last_published_item ~= "never" then
+		local ok, id, item = service:get_last_item(node, jid);
+		if not (ok and id) then return; end
+		service.config.broadcaster("items", node, { [jid] = true }, item);
+	end
 end
 
 function handlers.set_unsubscribe(origin, stanza, unsubscribe, service)
@@ -508,7 +565,13 @@
 	local ok, ret = service:remove_subscription(node, stanza.attr.from, jid);
 	local reply;
 	if ok then
-		reply = st.reply(stanza);
+		reply = st.reply(stanza)
+			:tag("pubsub", { xmlns = xmlns_pubsub })
+				:tag("subscription", {
+					node = node,
+					jid = jid,
+					subscription = "none"
+				}):up();
 	else
 		reply = pubsub_error_reply(stanza, ret);
 	end
@@ -592,6 +655,9 @@
 			item.attr.id = id;
 		end
 	end
+	if item then
+		item.attr.publisher = service.config.normalize_jid(stanza.attr.from);
+	end
 	local ok, ret = service:publish(node, stanza.attr.from, id, item, required_config);
 	local reply;
 	if ok then
@@ -633,14 +699,13 @@
 end
 
 function handlers.owner_set_purge(origin, stanza, purge, service)
-	local node, notify = purge.attr.node, purge.attr.notify;
-	notify = (notify == "1") or (notify == "true");
+	local node = purge.attr.node;
 	local reply;
 	if not node then
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
-	local ok, ret = service:purge(node, stanza.attr.from, notify);
+	local ok, ret = service:purge(node, stanza.attr.from, true);
 	if ok then
 		reply = st.reply(stanza);
 	else
@@ -781,16 +846,15 @@
 	return true;
 end
 
-local function create_encapsulating_item(id, payload)
-	local item = st.stanza("item", { id = id, xmlns = xmlns_pubsub });
+local function create_encapsulating_item(id, payload, publisher)
+	local item = st.stanza("item", { id = id, publisher = publisher, xmlns = xmlns_pubsub });
 	item:add_child(payload);
 	return item;
 end
 
-local function archive_itemstore(archive, config, user, node)
-	module:log("debug", "Creation of itemstore for node %s with config %s", node, config);
+local function archive_itemstore(archive, max_items, user, node)
+	module:log("debug", "Creation of archive itemstore for node %s with limit %d", node, max_items);
 	local get_set = {};
-	local max_items = config["max_items"];
 	function get_set:items() -- luacheck: ignore 212/self
 		local data, err = archive:find(user, {
 			limit = tonumber(max_items);
@@ -801,14 +865,15 @@
 			return true;
 		end
 		module:log("debug", "Listed items %s", data);
-		return it.reverse(function()
+		return function()
+			-- luacheck: ignore 211/when
 			local id, payload, when, publisher = data();
 			if id == nil then
 				return;
 			end
 			local item = create_encapsulating_item(id, payload, publisher);
 			return id, item;
-		end);
+		end;
 	end
 	function get_set:get(key) -- luacheck: ignore 212/self
 		local data, err = archive:find(user, {
@@ -863,7 +928,7 @@
 			return item.attr.id, item;
 		end
 	end
-	return setmetatable(get_set, archive);
+	return get_set;
 end
 _M.archive_itemstore = archive_itemstore;
 
--- a/plugins/mod_register.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_register.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -11,6 +11,7 @@
 
 if allow_registration then
 	module:depends("register_ibr");
+	module:depends("watchregistrations");
 end
 
 module:depends("user_account_management");
--- a/plugins/mod_register_ibr.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_register_ibr.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,14 +9,16 @@
 
 local st = require "util.stanza";
 local dataform_new = require "util.dataforms".new;
-local usermanager_user_exists = require "core.usermanager".user_exists;
-local usermanager_create_user = require "core.usermanager".create_user;
-local usermanager_delete_user = require "core.usermanager".delete_user;
+local usermanager_user_exists  = require "core.usermanager".user_exists;
+local usermanager_create_user  = require "core.usermanager".create_user;
+local usermanager_set_password = require "core.usermanager".create_user;
+local usermanager_delete_user  = require "core.usermanager".delete_user;
 local nodeprep = require "util.encodings".stringprep.nodeprep;
+local util_error = require "util.error";
 
 local additional_fields = module:get_option("additional_registration_fields", {});
 local require_encryption = module:get_option_boolean("c2s_require_encryption",
-	module:get_option_boolean("require_encryption", false));
+	module:get_option_boolean("require_encryption", true));
 
 pcall(function ()
 	module:depends("register_limits");
@@ -155,7 +157,7 @@
 		return true;
 	end
 
-	local username, password = nodeprep(data.username), data.password;
+	local username, password = nodeprep(data.username, true), data.password;
 	data.username, data.password = nil, nil;
 	local host = module.host;
 	if not username or username == "" then
@@ -167,25 +169,44 @@
 	local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true }
 	module:fire_event("user-registering", user);
 	if not user.allowed then
-		log("debug", "Registration disallowed by module: %s", user.reason or "no reason given");
-		session.send(st.error_reply(stanza, "modify", "not-acceptable", user.reason));
+		local error_type, error_condition, reason;
+		local err = user.error;
+		if err then
+			error_type, error_condition, reason = err.type, err.condition, err.text;
+		else
+			-- COMPAT pre-util.error
+			error_type, error_condition, reason = user.error_type, user.error_condition, user.reason;
+		end
+		log("debug", "Registration disallowed by module: %s", reason or "no reason given");
+		session.send(st.error_reply(stanza, error_type or "modify", error_condition or "not-acceptable", reason));
 		return true;
 	end
 
 	if usermanager_user_exists(username, host) then
-		log("debug", "Attempt to register with existing username");
-		session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists."));
-		return true;
+		if user.allow_reset == username then
+			local ok, err = util_error.coerce(usermanager_set_password(username, password, host));
+			if ok then
+				module:fire_event("user-password-reset", user);
+				session.send(st.reply(stanza)); -- reset ok!
+			else
+				session.log("error", "Unable to reset password for %s@%s: %s", username, host, err);
+				session.send(st.error_reply(stanza, err.type, err.condition, err.text));
+			end
+			return true;
+		else
+			log("debug", "Attempt to register with existing username");
+			session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists."));
+			return true;
+		end
 	end
 
-	-- TODO unable to write file, file may be locked, etc, what's the correct error?
-	local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk.");
-	if usermanager_create_user(username, password, host) then
+	local created, err = usermanager_create_user(username, password, host);
+	if created then
 		data.registered = os.time();
 		if not account_details:set(username, data) then
 			log("debug", "Could not store extra details");
 			usermanager_delete_user(username, host);
-			session.send(error_reply);
+			session.send(st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk."));
 			return true;
 		end
 		session.send(st.reply(stanza)); -- user created!
@@ -194,8 +215,8 @@
 			username = username, host = host, source = "mod_register",
 			session = session });
 	else
-		log("debug", "Could not create user");
-		session.send(error_reply);
+		log("debug", "Could not create user", err);
+		session.send(st.error_reply(stanza, "cancel", "feature-not-implemented", err));
 	end
 	return true;
 end);
--- a/plugins/mod_register_limits.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_register_limits.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -13,21 +13,24 @@
 local new_ip = ip_util.new_ip;
 local match_ip = ip_util.match;
 local parse_cidr = ip_util.parse_cidr;
+local errors = require "util.error";
 
+-- COMPAT drop old option names
 local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations");
-local whitelist_only = module:get_option_boolean("whitelist_registration_only");
-local whitelisted_ips = module:get_option_set("registration_whitelist", { "127.0.0.1", "::1" })._items;
-local blacklisted_ips = module:get_option_set("registration_blacklist", {})._items;
+local allowlist_only = module:get_option_boolean("allowlist_registration_only", module:get_option_boolean("whitelist_registration_only"));
+local allowlisted_ips = module:get_option_set("registration_allowlist", module:get_option("registration_whitelist", { "127.0.0.1", "::1" }))._items;
+local blocklisted_ips = module:get_option_set("registration_blocklist", module:get_option_set("registration_blacklist", {}))._items;
 
 local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1);
 local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations);
 local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100);
-local blacklist_overflow = module:get_option_boolean("blacklist_on_registration_throttle_overload", false);
+local blocklist_overflow = module:get_option_boolean("blocklist_on_registration_throttle_overload",
+	module:get_option_boolean("blacklist_on_registration_throttle_overload", false));
 
-local throttle_cache = new_cache(throttle_cache_size, blacklist_overflow and function (ip, throttle)
+local throttle_cache = new_cache(throttle_cache_size, blocklist_overflow and function (ip, throttle)
 	if not throttle:peek() then
-		module:log("info", "Adding ip %s to registration blacklist", ip);
-		blacklisted_ips[ip] = true;
+		module:log("info", "Adding ip %s to registration blocklist", ip);
+		blocklisted_ips[ip] = true;
 	end
 end or nil);
 
@@ -54,25 +57,49 @@
 	return false;
 end
 
+local err_registry = {
+	blocklisted = {
+		text = "Your IP address is blocklisted";
+		type = "auth";
+		condition = "forbidden";
+	};
+	not_allowlisted = {
+		text = "Your IP address is not allowlisted";
+		type = "auth";
+		condition = "forbidden";
+	};
+	throttled = {
+		text = "Too many registrations from this IP address recently";
+		type = "wait";
+		condition = "policy-violation";
+	};
+}
+
 module:hook("user-registering", function (event)
 	local session = event.session;
 	local ip = event.ip or session and session.ip;
 	local log = session and session.log or module._log;
 	if not ip then
-		log("warn", "IP not known; can't apply blacklist/whitelist");
-	elseif ip_in_set(blacklisted_ips, ip) then
-		log("debug", "Registration disallowed by blacklist");
+		log("warn", "IP not known; can't apply blocklist/allowlist");
+	elseif ip_in_set(blocklisted_ips, ip) then
+		log("debug", "Registration disallowed by blocklist");
 		event.allowed = false;
-		event.reason = "Your IP address is blacklisted";
-	elseif (whitelist_only and not ip_in_set(whitelisted_ips, ip)) then
-		log("debug", "Registration disallowed by whitelist");
+		event.error = errors.new("blocklisted", event, err_registry);
+	elseif (allowlist_only and not ip_in_set(allowlisted_ips, ip)) then
+		log("debug", "Registration disallowed by allowlist");
 		event.allowed = false;
-		event.reason = "Your IP address is not whitelisted";
-	elseif throttle_max and not ip_in_set(whitelisted_ips, ip) then
+		event.error = errors.new("not_allowlisted", event, err_registry);
+	elseif throttle_max and not ip_in_set(allowlisted_ips, ip) then
 		if not check_throttle(ip) then
 			log("debug", "Registrations over limit for ip %s", ip or "?");
 			event.allowed = false;
-			event.reason = "Too many registrations from this IP address recently";
+			event.error = errors.new("throttled", event, err_registry);
 		end
 	end
+	if event.error then
+		-- COMPAT pre-util.error
+		event.reason = event.error.text;
+		event.error_type = event.error.type;
+		event.error_condition = event.error.condition;
+	end
 end);
--- a/plugins/mod_roster.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_roster.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,6 +10,7 @@
 local st = require "util.stanza"
 
 local jid_split = require "util.jid".split;
+local jid_resource = require "util.jid".resource;
 local jid_prep = require "util.jid".prep;
 local tonumber = tonumber;
 local pairs = pairs;
@@ -66,15 +67,14 @@
 			local item = query.tags[1];
 			local from_node, from_host = jid_split(stanza.attr.from);
 			local jid = jid_prep(item.attr.jid);
-			local node, host, resource = jid_split(jid);
-			if not resource and host then
+			if jid and not jid_resource(jid) then
 				if jid ~= from_node.."@"..from_host then
 					if item.attr.subscription == "remove" then
 						local roster = session.roster;
 						local r_item = roster[jid];
 						if r_item then
 							module:fire_event("roster-item-removed", {
-								username = node, jid = jid, item = r_item, origin = session, roster = roster,
+								username = from_node, jid = jid, item = r_item, origin = session, roster = roster,
 							});
 							local success, err_type, err_cond, err_msg = rm_remove_from_roster(session, jid);
 							if success then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_s2s.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,1038 @@
+-- Prosody IM
+-- 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.
+--
+
+module:set_global();
+
+local prosody = prosody;
+local hosts = prosody.hosts;
+local core_process_stanza = prosody.core_process_stanza;
+
+local tostring, type = tostring, type;
+local t_insert = table.insert;
+local traceback = debug.traceback;
+
+local add_task = require "util.timer".add_task;
+local stop_timer = require "util.timer".stop;
+local st = require "util.stanza";
+local initialize_filters = require "util.filters".initialize;
+local nameprep = require "util.encodings".stringprep.nameprep;
+local new_xmpp_stream = require "util.xmppstream".new;
+local s2s_new_incoming = require "core.s2smanager".new_incoming;
+local s2s_new_outgoing = require "core.s2smanager".new_outgoing;
+local s2s_destroy_session = require "core.s2smanager".destroy_session;
+local uuid_gen = require "util.uuid".generate;
+local async = require "util.async";
+local runner = async.runner;
+local connect = require "net.connect".connect;
+local service = require "net.resolvers.service";
+local resolver_chain = require "net.resolvers.chain";
+local errors = require "util.error";
+local set = require "util.set";
+
+local connect_timeout = module:get_option_number("s2s_timeout", 90);
+local stream_close_timeout = module:get_option_number("s2s_close_timeout", 5);
+local opt_keepalives = module:get_option_boolean("s2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
+local secure_auth = module:get_option_boolean("s2s_secure_auth", false); -- One day...
+local secure_domains, insecure_domains =
+	module:get_option_set("s2s_secure_domains", {})._items, module:get_option_set("s2s_insecure_domains", {})._items;
+local require_encryption = module:get_option_boolean("s2s_require_encryption", true);
+local stanza_size_limit = module:get_option_number("s2s_stanza_size_limit", 1024*512);
+
+local measure_connections_inbound = module:metric(
+	"gauge", "connections_inbound", "",
+	"Established incoming s2s connections",
+	{"host", "type", "ip_family"}
+);
+local measure_connections_outbound = module:metric(
+	"gauge", "connections_outbound", "",
+	"Established outgoing s2s connections",
+	{"host", "type", "ip_family"}
+);
+
+local m_accepted_tcp_connections = module:metric(
+	"counter", "accepted_tcp", "",
+	"Accepted incoming connections on the TCP layer"
+);
+local m_authn_connections = module:metric(
+	"counter", "authenticated", "",
+	"Authenticated incoming connections",
+	{"host", "direction", "mechanism"}
+);
+local m_initiated_connections = module:metric(
+	"counter", "initiated", "",
+	"Initiated outbound connections",
+	{"host"}
+);
+local m_closed_connections = module:metric(
+	"counter", "closed", "",
+	"Closed connections",
+	{"host", "direction", "error"}
+);
+local m_tls_params = module:metric(
+	"counter", "encrypted", "",
+	"Encrypted connections",
+	{"protocol"; "cipher"}
+);
+
+local sessions = module:shared("sessions");
+
+local runner_callbacks = {};
+
+local listener = {};
+
+local log = module._log;
+
+local s2s_service_options = {
+	default_port = 5269;
+	use_ipv4 = module:get_option_boolean("use_ipv4", true);
+	use_ipv6 = module:get_option_boolean("use_ipv6", true);
+	use_dane = module:get_option_boolean("use_dane", false);
+};
+local s2s_service_options_mt = { __index = s2s_service_options }
+
+module:hook("stats-update", function ()
+	measure_connections_inbound:clear()
+	measure_connections_outbound:clear()
+	-- TODO: init all expected metrics once?
+	-- or maybe create/delete them in host-activate/host-deactivate? requires
+	-- extra API in openmetrics.lua tho
+	for _, session in pairs(sessions) do
+		local is_inbound = string.sub(session.type, 4, 5) == "in"
+		local metric_family = is_inbound and measure_connections_inbound or measure_connections_outbound
+		local host = is_inbound and session.to_host or session.from_host or ""
+		local type_ = session.type or "other"
+
+		-- we want to expose both v4 and v6 counters in all cases to make
+		-- queries smoother
+		local is_ipv6 = session.ip and session.ip:match(":") and 1 or 0
+		local is_ipv4 = 1 - is_ipv6
+		metric_family:with_labels(host, type_, "ipv4"):add(is_ipv4)
+		metric_family:with_labels(host, type_, "ipv6"):add(is_ipv6)
+	end
+end);
+
+--- Handle stanzas to remote domains
+
+local bouncy_stanzas = { message = true, presence = true, iq = true };
+local function bounce_sendq(session, reason)
+	local sendq = session.sendq;
+	if not sendq then return; end
+	session.log("info", "Sending error replies for %d queued stanzas because of failed outgoing connection to %s", #sendq, session.to_host);
+	local dummy = {
+		type = "s2sin";
+		send = function ()
+			(session.log or log)("error", "Replying to to an s2s error reply, please report this! Traceback: %s", traceback());
+		end;
+		dummy = true;
+		close = function ()
+			(session.log or log)("error", "Attempting to close the dummy origin of s2s error replies, please report this! Traceback: %s", traceback());
+		end;
+	};
+	-- FIXME Allow for more specific error conditions
+	-- TODO use util.error ?
+	local error_type = "cancel";
+	local condition = "remote-server-not-found";
+	local reason_text;
+	if session.had_stream then -- set when a stream is opened by the remote
+		error_type, condition = "wait", "remote-server-timeout";
+	end
+	if errors.is_err(reason) then
+		error_type, condition, reason_text = reason.type, reason.condition, reason.text;
+	elseif type(reason) == "string" then
+		reason_text = reason;
+	end
+	for i, data in ipairs(sendq) do
+		local reply = data[2];
+		if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then
+			reply.attr.type = "error";
+			reply:tag("error", {type = error_type, by = session.from_host})
+				:tag(condition, {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up();
+			if reason_text then
+				reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"})
+					:text("Server-to-server connection failed: "..reason_text):up();
+			end
+			core_process_stanza(dummy, reply);
+		end
+		sendq[i] = nil;
+	end
+	session.sendq = nil;
+end
+
+-- Handles stanzas to existing s2s sessions
+function route_to_existing_session(event)
+	local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
+	if not hosts[from_host] then
+		log("warn", "Attempt to send stanza from %s - a host we don't serve", from_host);
+		return false;
+	end
+	if hosts[to_host] then
+		log("warn", "Attempt to route stanza to a remote %s - a host we do serve?!", from_host);
+		return false;
+	end
+	local host = hosts[from_host].s2sout[to_host];
+	if not host then return end
+
+	-- We have a connection to this host already
+	if host.type == "s2sout_unauthed" and (stanza.name ~= "db:verify" or not host.dialback_key) then
+		(host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host);
+
+		-- Queue stanza until we are able to send it
+		local queued_item = {
+			tostring(stanza),
+			stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza);
+		};
+		if host.sendq then
+			t_insert(host.sendq, queued_item);
+		else
+			-- luacheck: ignore 122
+			host.sendq = { queued_item };
+		end
+		host.log("debug", "stanza [%s] queued ", stanza.name);
+		return true;
+	elseif host.type == "local" or host.type == "component" then
+		log("error", "Trying to send a stanza to ourselves??")
+		log("error", "Traceback: %s", traceback());
+		log("error", "Stanza: %s", stanza);
+		return false;
+	else
+		if host.sends2s(stanza) then
+			return true;
+		end
+	end
+end
+
+-- Create a new outgoing session for a stanza
+function route_to_new_session(event)
+	local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
+	log("debug", "opening a new outgoing connection for this stanza");
+	local host_session = s2s_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", stanza.name);
+	-- FIXME Cleaner solution to passing extra data from resolvers to net.server
+	-- This mt-clone allows resolvers to add extra data, currently used for DANE TLSA records
+	module:context(from_host):fire_event("s2sout-created", { session = host_session });
+	local xmpp_extra = setmetatable({}, s2s_service_options_mt);
+	local resolver = service.new(to_host, "xmpp-server", "tcp", xmpp_extra);
+	if host_session.ssl_ctx then
+		local sslctx = host_session.ssl_ctx;
+		local xmpps_extra = setmetatable({ default_port = false; servername = to_host; sslctx = sslctx }, s2s_service_options_mt);
+		resolver = resolver_chain.new({
+			service.new(to_host, "xmpps-server", "tcp", xmpps_extra);
+			resolver;
+		});
+	end
+	connect(resolver, listener, nil, { session = host_session });
+	m_initiated_connections:with_labels(from_host):add(1)
+	return true;
+end
+
+local function keepalive(event)
+	local session = event.session;
+	if not session.notopen then
+		return event.session.sends2s(' ');
+	end
+end
+
+module:hook("s2s-read-timeout", keepalive, -1);
+
+function module.add_host(module)
+	if module:get_option_boolean("disallow_s2s", false) then
+		module:log("warn", "The 'disallow_s2s' config option is deprecated, please see https://prosody.im/doc/s2s#disabling");
+		return nil, "This host has disallow_s2s set";
+	end
+	module:hook("route/remote", route_to_existing_session, -1);
+	module:hook("route/remote", route_to_new_session, -10);
+	module:hook("s2s-authenticated", make_authenticated, -1);
+	module:hook("s2s-read-timeout", keepalive, -1);
+	module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) -- luacheck: ignore 212/stanza
+		if session.type == "s2sout" then
+			-- Stream is authenticated and we are seem to be done with feature negotiation,
+			-- so the stream is ready for stanzas.  RFC 6120 Section 4.3
+			mark_connected(session);
+			return true;
+		elseif require_encryption and not session.secure then
+			session.log("warn", "Encrypted server-to-server communication is required but was not offered by %s", session.to_host);
+			session:close({
+					condition = "policy-violation",
+					text = "Encrypted server-to-server communication is required but was not offered",
+				}, nil, "Could not establish encrypted connection to remote server");
+			return true;
+		elseif not session.dialback_verifying then
+			session.log("warn", "No SASL EXTERNAL offer and Dialback doesn't seem to be enabled, giving up");
+			session:close({
+					condition = "unsupported-feature",
+					text = "No viable authentication method offered",
+				}, nil, "No viable authentication method offered by remote server");
+			return true;
+		end
+	end, -1);
+
+	function module.unload()
+		if module.reloading then return end
+		for _, session in pairs(sessions) do
+			if session.to_host == module.host or session.from_host == module.host then
+				session:close("host-gone");
+			end
+		end
+	end
+end
+
+-- Stream is authorised, and ready for normal stanzas
+function mark_connected(session)
+
+	local sendq = session.sendq;
+
+	local from, to = session.from_host, session.to_host;
+
+	session.log("info", "%s s2s connection %s->%s complete", session.direction:gsub("^.", string.upper), from, to);
+
+	local event_data = { session = session };
+	if session.type == "s2sout" then
+		module:fire_event("s2sout-established", event_data);
+		module:context(from):fire_event("s2sout-established", event_data);
+
+		if session.incoming then
+			session.send = function(stanza)
+				return module:context(from):fire_event("route/remote", { from_host = from, to_host = to, stanza = stanza });
+			end;
+		end
+
+	else
+		if session.outgoing and not hosts[to].s2sout[from] then
+			session.log("debug", "Setting up to handle route from %s to %s", to, from);
+			hosts[to].s2sout[from] = session; -- luacheck: ignore 122
+		end
+		local host_session = hosts[to];
+		session.send = function(stanza)
+			return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza });
+		end;
+
+		module:fire_event("s2sin-established", event_data);
+		module:context(to):fire_event("s2sin-established", event_data);
+	end
+
+	if session.direction == "outgoing" then
+		if sendq then
+			session.log("debug", "sending %d queued stanzas across new outgoing connection to %s", #sendq, session.to_host);
+			local send = session.sends2s;
+			for i, data in ipairs(sendq) do
+				send(data[1]);
+				sendq[i] = nil;
+			end
+			session.sendq = nil;
+		end
+	end
+
+	if session.connect_timeout then
+		stop_timer(session.connect_timeout);
+		session.connect_timeout = nil;
+	end
+end
+
+function make_authenticated(event)
+	local session, host = event.session, event.host;
+	if not session.secure then
+		if require_encryption or (secure_auth and not(insecure_domains[host])) or secure_domains[host] then
+			session:close({
+				condition = "policy-violation",
+				text = "Encrypted server-to-server communication is required but was not "
+				       ..((session.direction == "outgoing" and "offered") or "used")
+			}, nil, "Could not establish encrypted connection to remote server");
+		end
+	end
+
+	if session.type == "s2sout_unauthed" and not session.authenticated_remote and secure_auth and not insecure_domains[host] then
+		session:close({
+			condition = "policy-violation";
+			text = "Failed to verify certificate (internal error)";
+		});
+		return;
+	end
+
+	if hosts[host] then
+		session:close({ condition = "undefined-condition", text = "Attempt to authenticate as a host we serve" });
+	end
+	if session.type == "s2sout_unauthed" then
+		session.type = "s2sout";
+	elseif session.type == "s2sin_unauthed" then
+		session.type = "s2sin";
+	elseif session.type ~= "s2sin" and session.type ~= "s2sout" then
+		return false;
+	end
+
+	if session.incoming and host then
+		if not session.hosts[host] then session.hosts[host] = {}; end
+		session.hosts[host].authed = true;
+	end
+	session.log("debug", "connection %s->%s is now authenticated for %s", session.from_host, session.to_host, host);
+
+	local local_host = session.direction == "incoming" and session.to_host or session.from_host
+	m_authn_connections:with_labels(local_host, session.direction, event.mechanism or "other"):add(1)
+
+	if (session.type == "s2sout" and session.external_auth ~= "succeeded") or session.type == "s2sin" then
+		-- Stream either used dialback for authentication or is an incoming stream.
+		mark_connected(session);
+	end
+
+	return true;
+end
+
+--- Helper to check that a session peer's certificate is valid
+local function check_cert_status(session)
+	local host = session.direction == "outgoing" and session.to_host or session.from_host
+	local conn = session.conn:socket()
+	local cert
+	if conn.getpeercertificate then
+		cert = conn:getpeercertificate()
+	end
+
+	return module:fire_event("s2s-check-certificate", { host = host, session = session, cert = cert });
+end
+
+--- XMPP stream event handlers
+
+local function session_secure(session)
+	session.secure = true;
+	session.encrypted = true;
+
+	local sock = session.conn:socket();
+	local info = sock.info and sock:info();
+	if type(info) == "table" then
+		(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
+		session.compressed = info.compression;
+		m_tls_params:with_labels(info.protocol, info.cipher):add(1)
+	else
+		(session.log or log)("info", "Stream encrypted");
+	end
+end
+
+local stream_callbacks = { default_ns = "jabber:server" };
+
+function stream_callbacks.handlestanza(session, stanza)
+	stanza = session.filter("stanzas/in", stanza);
+	session.thread:run(stanza);
+end
+
+local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
+
+function stream_callbacks.streamopened(session, attr)
+	-- run _streamopened in async context
+	session.thread:run({ stream = "opened", attr = attr });
+end
+
+function stream_callbacks._streamopened(session, attr)
+	session.version = tonumber(attr.version) or 0;
+	session.had_stream = true; -- Had a stream opened at least once
+
+	-- TODO: Rename session.secure to session.encrypted
+	if session.secure == false then
+		session_secure(session);
+	end
+
+	if session.direction == "incoming" then
+		-- Send a reply stream header
+
+		-- Validate to/from
+		local to, from = attr.to, attr.from;
+		if to then to = nameprep(attr.to); end
+		if from then from = nameprep(attr.from); end
+		if not to and attr.to then -- COMPAT: Some servers do not reliably set 'to' (especially on stream restarts)
+			session:close({ condition = "improper-addressing", text = "Invalid 'to' address" });
+			return;
+		end
+		if not from and attr.from then -- COMPAT: Some servers do not reliably set 'from' (especially on stream restarts)
+			session:close({ condition = "improper-addressing", text = "Invalid 'from' address" });
+			return;
+		end
+
+		-- Set session.[from/to]_host if they have not been set already and if
+		-- this session isn't already authenticated
+		if session.type == "s2sin_unauthed" and from and not session.from_host then
+			session.from_host = from;
+		elseif from ~= session.from_host then
+			session:close({ condition = "improper-addressing", text = "New stream 'from' attribute does not match original" });
+			return;
+		end
+		if session.type == "s2sin_unauthed" and to and not session.to_host then
+			session.to_host = to;
+			session.host = to;
+		elseif to ~= session.to_host then
+			session:close({ condition = "improper-addressing", text = "New stream 'to' attribute does not match original" });
+			return;
+		end
+
+		-- For convenience we'll put the sanitised values into these variables
+		to, from = session.to_host, session.from_host;
+
+		session.streamid = uuid_gen();
+		(session.log or log)("debug", "Incoming s2s received %s", st.stanza("stream:stream", attr):top_tag());
+		if to then
+			if not hosts[to] then
+				-- Attempting to connect to a host we don't serve
+				session:close({
+					condition = "host-unknown";
+					text = "This host does not serve "..to
+				});
+				return;
+			elseif not hosts[to].modules.s2s then
+				-- Attempting to connect to a host that disallows s2s
+				session:close({
+					condition = "policy-violation";
+					text = "Server-to-server communication is disabled for this host";
+				});
+				return;
+			end
+		end
+
+		if hosts[from] then
+			session:close({ condition = "undefined-condition", text = "Attempt to connect from a host we serve" });
+			return;
+		end
+
+		if session.secure and not session.cert_chain_status then
+			if check_cert_status(session) == false then
+				return;
+			end
+		end
+
+		session:open_stream(session.to_host, session.from_host)
+		if session.destroyed then
+			-- sending the stream opening could have failed during an opportunistic write
+			return
+		end
+
+		session.notopen = nil;
+		if session.version >= 1.0 then
+			local features = st.stanza("stream:features");
+
+			if to then
+				module:context(to):fire_event("s2s-stream-features", { origin = session, features = features });
+			else
+				(session.log or log)("warn", "No 'to' on stream header from %s means we can't offer any features", from or session.ip or "unknown host");
+				module:fire_event("s2s-stream-features-legacy", { origin = session, features = features });
+			end
+
+			if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then
+				log("debug", "Sending stream features: %s", features);
+				session.sends2s(features);
+			else
+				(session.log or log)("warn", "No stream features to offer, giving up");
+				session:close({ condition = "undefined-condition", text = "No stream features to offer" });
+			end
+		end
+	elseif session.direction == "outgoing" then
+		session.notopen = nil;
+		if not attr.id then
+			log("warn", "Stream response did not give us a stream id!");
+			session:close({ condition = "undefined-condition", text = "Missing stream ID" });
+			return;
+		end
+		session.streamid = attr.id;
+
+		if session.secure and not session.cert_chain_status then
+			if check_cert_status(session) == false then
+				return;
+			else
+				session.authenticated_remote = true;
+			end
+		end
+
+		-- If server is pre-1.0, don't wait for features, just do dialback
+		if session.version < 1.0 then
+			if not session.dialback_verifying then
+				module:context(session.from_host):fire_event("s2sout-authenticate-legacy", { origin = session });
+			else
+				mark_connected(session);
+			end
+		end
+	end
+end
+
+function stream_callbacks._streamclosed(session)
+	(session.log or log)("debug", "Received </stream:stream>");
+	session:close(false);
+end
+
+function stream_callbacks.streamclosed(session, attr)
+	-- run _streamclosed in async context
+	session.thread:run({ stream = "closed", attr = attr });
+end
+
+-- Some stream conditions indicate a problem on our end, e.g. that we sent
+-- something invalid. Those should be investigated. Others are problems or
+-- events in the remote host that don't affect us, or simply that the
+-- connection was closed for being idle.
+local stream_condition_severity = {
+	["bad-format"] = "warn";
+	["bad-namespace-prefix"] = "warn";
+	["conflict"] = "warn";
+	["connection-timeout"] = "debug";
+	["host-gone"] = "info";
+	["host-unknown"] = "info";
+	["improper-addressing"] = "warn";
+	["internal-server-error"] = "warn";
+	["invalid-from"] = "warn";
+	["invalid-namespace"] = "warn";
+	["invalid-xml"] = "warn";
+	["not-authorized"] = "warn";
+	["not-well-formed"] = "warn";
+	["policy-violation"] = "warn";
+	["remote-connection-failed"] = "warn";
+	["reset"] = "info";
+	["resource-constraint"] = "info";
+	["restricted-xml"] = "warn";
+	["see-other-host"] = "info";
+	["system-shutdown"] = "info";
+	["undefined-condition"] = "warn";
+	["unsupported-encoding"] = "warn";
+	["unsupported-feature"] = "warn";
+	["unsupported-stanza-type"] = "warn";
+	["unsupported-version"] = "warn";
+}
+
+function stream_callbacks.error(session, error, data)
+	if error == "no-stream" then
+		session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
+		session:close("invalid-namespace");
+	elseif error == "parse-error" then
+		session.log("debug", "Server-to-server XML parse error: %s", error);
+		session:close("not-well-formed");
+	elseif error == "stream-error" then
+		local condition, text = "undefined-condition";
+		for child in data:childtags(nil, xmlns_xmpp_streams) do
+			if child.name ~= "text" then
+				condition = child.name;
+			else
+				text = child:get_text();
+			end
+			if condition ~= "undefined-condition" and text then
+				break;
+			end
+		end
+		text = condition .. (text and (" ("..text..")") or "");
+		session.log(stream_condition_severity[condition] or "info", "Session closed by remote with error: %s", text);
+		session:close(nil, text);
+	end
+end
+
+--- Session methods
+local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'};
+-- reason: stream error to send to the remote server
+-- remote_reason: stream error received from the remote server
+-- bounce_reason: stanza error to pass to bounce_sendq because stream- and stanza errors are different
+local function session_close(session, reason, remote_reason, bounce_reason)
+	local log = session.log or log;
+	if not session.conn then
+		log("debug", "Attempt to close without associated connection with reason %q", reason);
+		return
+	end
+
+	local conn = session.conn;
+	conn:pause_writes(); -- until :close
+	if session.notopen then
+		if session.direction == "incoming" then
+			session:open_stream(session.to_host, session.from_host);
+		else
+			session:open_stream(session.from_host, session.to_host);
+		end
+	end
+
+	local this_host = session.direction == "outgoing" and session.from_host or session.to_host
+	if not hosts[this_host] then this_host = ":unknown"; end
+
+	if reason then -- nil == no err, initiated by us, false == initiated by remote
+		local stream_error;
+		local condition, text, extra
+		if type(reason) == "string" then -- assume stream error
+			condition = reason
+		elseif type(reason) == "table" and not st.is_stanza(reason) then
+			condition = reason.condition or "undefined-condition"
+			text = reason.text
+			extra = reason.extra
+		end
+		if condition then
+			stream_error = st.stanza("stream:error"):tag(condition, stream_xmlns_attr):up();
+			if text then
+				stream_error:tag("text", stream_xmlns_attr):text(text):up();
+			end
+			if extra then
+				stream_error:add_child(extra);
+			end
+		end
+		if this_host and condition then
+			m_closed_connections:with_labels(this_host, session.direction, condition):add(1)
+		end
+		if st.is_stanza(stream_error) then
+			-- to and from are never unknown on outgoing connections
+			log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s",
+				session.from_host or "(unknown host)" or session.ip, session.to_host or "(unknown host)", session.type, stream_error);
+			session.sends2s(stream_error);
+		end
+	else
+		m_closed_connections:with_labels(this_host or ":unknown", session.direction, reason == false and ":remote-choice" or ":local-choice"):add(1)
+	end
+
+	session.sends2s("</stream:stream>");
+	function session.sends2s() return false; end
+
+	-- luacheck: ignore 422/reason 412/reason
+	-- FIXME reason should be managed in a place common to c2s, s2s, bosh, component etc
+	local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason;
+	session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper),
+		session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed");
+
+	conn:resume_writes();
+
+	if session.connect_timeout then
+		stop_timer(session.connect_timeout);
+		session.connect_timeout = nil;
+	end
+
+	-- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote
+	if reason == nil and not session.notopen and session.direction == "incoming" then
+		add_task(stream_close_timeout, function ()
+			if not session.destroyed then
+				session.log("warn", "Failed to receive a stream close response, closing connection anyway...");
+				s2s_destroy_session(session, reason, bounce_reason);
+				conn:close();
+			end
+		end);
+	else
+		s2s_destroy_session(session, reason, bounce_reason);
+		conn:close(); -- Close immediately, as this is an outgoing connection or is not authed
+	end
+end
+
+function session_stream_attrs(session, from, to, attr) -- luacheck: ignore 212/session
+	if not from or (hosts[from] and hosts[from].modules.dialback) then
+		attr["xmlns:db"] = 'jabber:server:dialback';
+	end
+	if not from then
+		attr.from = '';
+	end
+	if not to then
+		attr.to = '';
+	end
+end
+
+-- Session initialization logic shared by incoming and outgoing
+local function initialize_session(session)
+	local stream = new_xmpp_stream(session, stream_callbacks, stanza_size_limit);
+
+	session.thread = runner(function (stanza)
+		if st.is_stanza(stanza) then
+			core_process_stanza(session, stanza);
+		elseif stanza.stream == "opened" then
+			stream_callbacks._streamopened(session, stanza.attr);
+		elseif stanza.stream == "closed" then
+			stream_callbacks._streamclosed(session, stanza.attr);
+		end
+	end, runner_callbacks, session);
+
+	local log = session.log or log;
+	session.stream = stream;
+
+	session.notopen = true;
+
+	function session.reset_stream()
+		session.notopen = true;
+		session.streamid = nil;
+		session.stream:reset();
+	end
+
+	session.stream_attrs = session_stream_attrs;
+
+	local filter = initialize_filters(session);
+	local conn = session.conn;
+	local w = conn.write;
+
+	if conn:ssl() then
+		session_secure(session);
+	end
+
+	function session.sends2s(t)
+		log("debug", "Sending[%s]: %s", session.type, 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 w(conn, t);
+			end
+		end
+	end
+
+	function session.data(data)
+		data = filter("bytes/in", data);
+		if data then
+			local ok, err = stream:feed(data);
+			if ok then return; end
+			log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
+			if err == "stanza-too-large" then
+				session:close({
+					condition = "policy-violation",
+					text = "XML stanza is too big",
+					extra = st.stanza("stanza-too-big", { xmlns = 'urn:xmpp:errors' }),
+				}, nil, "Received invalid XML from remote server");
+			else
+				session:close("not-well-formed", nil, "Received invalid XML from remote server");
+			end
+		end
+	end
+
+	session.close = session_close;
+
+	local handlestanza = stream_callbacks.handlestanza;
+	function session.dispatch_stanza(session, stanza) -- luacheck: ignore 432/session
+		return handlestanza(session, stanza);
+	end
+
+	module:fire_event("s2s-created", { session = session });
+
+	session.connect_timeout = add_task(connect_timeout, function ()
+		if session.type == "s2sin" or session.type == "s2sout" then
+			return; -- Ok, we're connected
+		elseif session.type == "s2s_destroyed" then
+			return; -- Session already destroyed
+		end
+		-- Not connected, need to close session and clean up
+		(session.log or log)("debug", "Destroying incomplete session %s->%s due to inactivity",
+		session.from_host or "(unknown)", session.to_host or "(unknown)");
+		session:close("connection-timeout");
+	end);
+end
+
+function runner_callbacks:ready()
+	self.data.log("debug", "Runner %s ready (%s)", self.thread, coroutine.status(self.thread));
+	self.data.conn:resume();
+end
+
+function runner_callbacks:waiting()
+	self.data.log("debug", "Runner %s waiting (%s)", self.thread, coroutine.status(self.thread));
+	self.data.conn:pause();
+end
+
+function runner_callbacks:error(err)
+	(self.data.log or log)("error", "Traceback[s2s]: %s", err);
+end
+
+function listener.onconnect(conn)
+	conn:setoption("keepalive", opt_keepalives);
+	local session = sessions[conn];
+	if not session then -- New incoming connection
+		session = s2s_new_incoming(conn);
+		sessions[conn] = session;
+		session.log("debug", "Incoming s2s connection");
+		module:fire_event("s2sin-connected", { session = session })
+		initialize_session(session);
+		m_accepted_tcp_connections:with_labels():add(1)
+	else -- Outgoing session connected
+		module:fire_event("s2sout-connected", { session = session })
+		session:open_stream(session.from_host, session.to_host);
+	end
+	module:fire_event("s2s-connected", { session = session })
+	session.ip = conn:ip();
+end
+
+function listener.onincoming(conn, data)
+	local session = sessions[conn];
+	if session then
+		session.data(data);
+	end
+end
+
+function listener.onstatus(conn, status)
+	if status == "ssl-handshake-complete" then
+		local session = sessions[conn];
+		if session and session.direction == "outgoing" then
+			session.log("debug", "Sending stream header...");
+			session:open_stream(session.from_host, session.to_host);
+		end
+	end
+end
+
+function listener.ondisconnect(conn, err)
+	local session = sessions[conn];
+	if session then
+		sessions[conn] = nil;
+		(session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed");
+		if session.secure == false and err then
+			-- TODO util.error-ify this
+			err = "Error during negotiation of encrypted connection: "..err;
+		end
+		s2s_destroy_session(session, err);
+	end
+	module:fire_event("s2s-closed", { session = session; conn = conn });
+end
+
+function listener.onfail(data, err)
+	local session = data and data.session;
+	if session then
+		if err and session.direction == "outgoing" and session.notopen then
+			(session.log or log)("debug", "s2s connection attempt failed: %s", err);
+		end
+		(session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed");
+		s2s_destroy_session(session, err);
+	end
+end
+
+function listener.onreadtimeout(conn)
+	local session = sessions[conn];
+	if session then
+		return (hosts[session.host] or prosody).events.fire_event("s2s-read-timeout", { session = session });
+	end
+end
+
+function listener.ondrain(conn)
+	local session = sessions[conn];
+	if session then
+		return (hosts[session.host] or prosody).events.fire_event("s2s-ondrain", { session = session });
+	end
+end
+
+function listener.onpredrain(conn)
+	local session = sessions[conn];
+	if session then
+		return (hosts[session.host] or prosody).events.fire_event("s2s-pre-ondrain", { session = session });
+	end
+end
+
+function listener.register_outgoing(conn, session)
+	sessions[conn] = session;
+	initialize_session(session);
+end
+
+function listener.ondetach(conn)
+	sessions[conn] = nil;
+end
+
+function listener.onattach(conn, data)
+	local session = data and data.session;
+	if session then
+		session.conn = conn;
+		sessions[conn] = session;
+		initialize_session(session);
+	end
+end
+
+-- Complete the sentence "Your certificate " with what's wrong
+local function friendly_cert_error(session) --> string
+	if session.cert_chain_status == "invalid" then
+		if session.cert_chain_errors then
+			local cert_errors = set.new(session.cert_chain_errors[1]);
+			if cert_errors:contains("certificate has expired") then
+				return "has expired";
+			elseif cert_errors:contains("self signed certificate") then
+				return "is self-signed";
+			end
+		end
+		return "is not trusted"; -- for some other reason
+	elseif session.cert_identity_status == "invalid" then
+		return "is not valid for this name";
+	end
+	-- this should normally be unreachable except if no s2s auth module was loaded
+	return "could not be validated";
+end
+
+function check_auth_policy(event)
+	local host, session = event.host, event.session;
+	local must_secure = secure_auth;
+
+	if not must_secure and secure_domains[host] then
+		must_secure = true;
+	elseif must_secure and insecure_domains[host] then
+		must_secure = false;
+	end
+
+	if must_secure and (session.cert_chain_status ~= "valid" or session.cert_identity_status ~= "valid") then
+		local reason = friendly_cert_error(session);
+		session.log("warn", "Forbidding insecure connection to/from %s because its certificate %s", host or session.ip or "(unknown host)", reason);
+		-- XEP-0178 recommends closing outgoing connections without warning
+		-- but does not give a rationale for this.
+		-- In practice most cases are configuration mistakes or forgotten
+		-- certificate renewals. We think it's better to let the other party
+		-- know about the problem so that they can fix it.
+		session:close({ condition = "not-authorized", text = "Your server's certificate "..reason },
+			nil, "Remote server's certificate "..reason);
+		return false;
+	end
+end
+
+module:hook("s2s-check-certificate", check_auth_policy, -1);
+
+module:hook("server-stopping", function(event)
+	-- Close ports
+	local pm = require "core.portmanager";
+	for _, netservice in pairs(module.items["net-provider"]) do
+		pm.unregister_service(netservice.name, netservice);
+	end
+
+	-- Stop opening new connections
+	for host in pairs(prosody.hosts) do
+		if prosody.hosts[host].modules.s2s then
+			module:context(host):unhook("route/remote", route_to_new_session);
+		end
+	end
+
+	local wait, done = async.waiter(1, true);
+	module:hook("s2s-closed", function ()
+		if next(sessions) == nil then done(); end
+	end, 1)
+
+	-- Close sessions
+	local reason = event.reason;
+	for _, session in pairs(sessions) do
+		session:close{ condition = "system-shutdown", text = reason };
+	end
+
+	-- Wait for them to close properly if they haven't already
+	if next(sessions) ~= nil then
+		module:log("info", "Waiting for sessions to close");
+		add_task(stream_close_timeout + 1, function () done() end);
+		wait();
+	end
+
+end, -200);
+
+
+
+module:provides("net", {
+	name = "s2s";
+	listener = listener;
+	default_port = 5269;
+	encryption = "starttls";
+	ssl_config = {
+		-- FIXME This only applies to Direct TLS, which we don't use yet.
+		-- This gets applied for real in mod_tls
+		verify = { "peer", "client_once", };
+	};
+	multiplex = {
+		protocol = "xmpp-server";
+		pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>";
+	};
+});
+
+
+module:provides("net", {
+	name = "s2s_direct_tls";
+	listener = listener;
+	encryption = "ssl";
+	ssl_config = {
+		verify = { "peer", "client_once", };
+	};
+	multiplex = {
+		protocol = "xmpp-server";
+		pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>";
+	};
+});
+
--- a/plugins/mod_s2s/mod_s2s.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,749 +0,0 @@
--- Prosody IM
--- 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.
---
-
-module:set_global();
-
-local prosody = prosody;
-local hosts = prosody.hosts;
-local core_process_stanza = prosody.core_process_stanza;
-
-local tostring, type = tostring, type;
-local t_insert = table.insert;
-local traceback = debug.traceback;
-
-local add_task = require "util.timer".add_task;
-local st = require "util.stanza";
-local initialize_filters = require "util.filters".initialize;
-local nameprep = require "util.encodings".stringprep.nameprep;
-local new_xmpp_stream = require "util.xmppstream".new;
-local s2s_new_incoming = require "core.s2smanager".new_incoming;
-local s2s_new_outgoing = require "core.s2smanager".new_outgoing;
-local s2s_destroy_session = require "core.s2smanager".destroy_session;
-local uuid_gen = require "util.uuid".generate;
-local fire_global_event = prosody.events.fire_event;
-local runner = require "util.async".runner;
-
-local s2sout = module:require("s2sout");
-
-local connect_timeout = module:get_option_number("s2s_timeout", 90);
-local stream_close_timeout = module:get_option_number("s2s_close_timeout", 5);
-local opt_keepalives = module:get_option_boolean("s2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
-local secure_auth = module:get_option_boolean("s2s_secure_auth", false); -- One day...
-local secure_domains, insecure_domains =
-	module:get_option_set("s2s_secure_domains", {})._items, module:get_option_set("s2s_insecure_domains", {})._items;
-local require_encryption = module:get_option_boolean("s2s_require_encryption", false);
-local stanza_size_limit = module:get_option_number("s2s_stanza_size_limit", 1024*512);
-
-local measure_connections = module:measure("connections", "amount");
-local measure_ipv6 = module:measure("ipv6", "amount");
-
-local sessions = module:shared("sessions");
-
-local runner_callbacks = {};
-
-local log = module._log;
-
-module:hook("stats-update", function ()
-	local count = 0;
-	local ipv6 = 0;
-	for _, session in pairs(sessions) do
-		count = count + 1;
-		if session.ip and session.ip:match(":") then
-			ipv6 = ipv6 + 1;
-		end
-	end
-	measure_connections(count);
-	measure_ipv6(ipv6);
-end);
-
---- Handle stanzas to remote domains
-
-local bouncy_stanzas = { message = true, presence = true, iq = true };
-local function bounce_sendq(session, reason)
-	local sendq = session.sendq;
-	if not sendq then return; end
-	session.log("info", "Sending error replies for %d queued stanzas because of failed outgoing connection to %s", #sendq, session.to_host);
-	local dummy = {
-		type = "s2sin";
-		send = function ()
-			(session.log or log)("error", "Replying to to an s2s error reply, please report this! Traceback: %s", traceback());
-		end;
-		dummy = true;
-		close = function ()
-			(session.log or log)("error", "Attempting to close the dummy origin of s2s error replies, please report this! Traceback: %s", traceback());
-		end;
-	};
-	for i, data in ipairs(sendq) do
-		local reply = data[2];
-		if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then
-			reply.attr.type = "error";
-			reply:tag("error", {type = "cancel", by = session.from_host})
-				:tag("remote-server-not-found", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up();
-			if reason then
-				reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"})
-					:text("Server-to-server connection failed: "..reason):up();
-			end
-			core_process_stanza(dummy, reply);
-		end
-		sendq[i] = nil;
-	end
-	session.sendq = nil;
-end
-
--- Handles stanzas to existing s2s sessions
-function route_to_existing_session(event)
-	local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
-	if not hosts[from_host] then
-		log("warn", "Attempt to send stanza from %s - a host we don't serve", from_host);
-		return false;
-	end
-	if hosts[to_host] then
-		log("warn", "Attempt to route stanza to a remote %s - a host we do serve?!", from_host);
-		return false;
-	end
-	local host = hosts[from_host].s2sout[to_host];
-	if host then
-		-- We have a connection to this host already
-		if host.type == "s2sout_unauthed" and (stanza.name ~= "db:verify" or not host.dialback_key) then
-			(host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host);
-
-			-- Queue stanza until we are able to send it
-			local queued_item = {
-				tostring(stanza),
-				stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza);
-			};
-			if host.sendq then
-				t_insert(host.sendq, queued_item);
-			else
-				-- luacheck: ignore 122
-				host.sendq = { queued_item };
-			end
-			host.log("debug", "stanza [%s] queued ", stanza.name);
-			return true;
-		elseif host.type == "local" or host.type == "component" then
-			log("error", "Trying to send a stanza to ourselves??")
-			log("error", "Traceback: %s", traceback());
-			log("error", "Stanza: %s", tostring(stanza));
-			return false;
-		else
-			-- FIXME
-			if host.from_host ~= from_host then
-				log("error", "WARNING! This might, possibly, be a bug, but it might not...");
-				log("error", "We are going to send from %s instead of %s", host.from_host, from_host);
-			end
-			if host.sends2s(stanza) then
-				return true;
-			end
-		end
-	end
-end
-
--- Create a new outgoing session for a stanza
-function route_to_new_session(event)
-	local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
-	log("debug", "opening a new outgoing connection for this stanza");
-	local host_session = s2s_new_outgoing(from_host, to_host);
-
-	-- 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));
-	s2sout.initiate_connection(host_session);
-	if (not host_session.connecting) and (not host_session.conn) then
-		log("warn", "Connection to %s failed already, destroying session...", to_host);
-		s2s_destroy_session(host_session, "Connection failed");
-		return false;
-	end
-	return true;
-end
-
-local function keepalive(event)
-	local session = event.session;
-	if not session.notopen then
-		return event.session.sends2s(' ');
-	end
-end
-
-module:hook("s2s-read-timeout", keepalive, -1);
-
-function module.add_host(module)
-	if module:get_option_boolean("disallow_s2s", false) then
-		module:log("warn", "The 'disallow_s2s' config option is deprecated, please see https://prosody.im/doc/s2s#disabling");
-		return nil, "This host has disallow_s2s set";
-	end
-	module:hook("route/remote", route_to_existing_session, -1);
-	module:hook("route/remote", route_to_new_session, -10);
-	module:hook("s2s-authenticated", make_authenticated, -1);
-	module:hook("s2s-read-timeout", keepalive, -1);
-	module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) -- luacheck: ignore 212/stanza
-		if session.type == "s2sout" then
-			-- Stream is authenticated and we are seem to be done with feature negotiation,
-			-- so the stream is ready for stanzas.  RFC 6120 Section 4.3
-			mark_connected(session);
-			return true;
-		elseif not session.dialback_verifying then
-			session.log("warn", "No SASL EXTERNAL offer and Dialback doesn't seem to be enabled, giving up");
-			session:close();
-			return false;
-		end
-	end, -1);
-end
-
--- Stream is authorised, and ready for normal stanzas
-function mark_connected(session)
-
-	local sendq = session.sendq;
-
-	local from, to = session.from_host, session.to_host;
-
-	session.log("info", "%s s2s connection %s->%s complete", session.direction:gsub("^.", string.upper), from, to);
-
-	local event_data = { session = session };
-	if session.type == "s2sout" then
-		fire_global_event("s2sout-established", event_data);
-		hosts[from].events.fire_event("s2sout-established", event_data);
-	else
-		local host_session = hosts[to];
-		session.send = function(stanza)
-			return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza });
-		end;
-
-		fire_global_event("s2sin-established", event_data);
-		hosts[to].events.fire_event("s2sin-established", event_data);
-	end
-
-	if session.direction == "outgoing" then
-		if sendq then
-			session.log("debug", "sending %d queued stanzas across new outgoing connection to %s", #sendq, session.to_host);
-			local send = session.sends2s;
-			for i, data in ipairs(sendq) do
-				send(data[1]);
-				sendq[i] = nil;
-			end
-			session.sendq = nil;
-		end
-
-		if session.resolver then
-			session.resolver._resolver:closeall()
-		end
-		session.resolver = nil;
-		session.ip_hosts = nil;
-		session.srv_hosts = nil;
-	end
-end
-
-function make_authenticated(event)
-	local session, host = event.session, event.host;
-	if not session.secure then
-		if require_encryption or (secure_auth and not(insecure_domains[host])) or secure_domains[host] then
-			session:close({
-				condition = "policy-violation",
-				text = "Encrypted server-to-server communication is required but was not "
-				       ..((session.direction == "outgoing" and "offered") or "used")
-			});
-		end
-	end
-	if hosts[host] then
-		session:close({ condition = "undefined-condition", text = "Attempt to authenticate as a host we serve" });
-	end
-	if session.type == "s2sout_unauthed" then
-		session.type = "s2sout";
-	elseif session.type == "s2sin_unauthed" then
-		session.type = "s2sin";
-		if host then
-			if not session.hosts[host] then session.hosts[host] = {}; end
-			session.hosts[host].authed = true;
-		end
-	elseif session.type == "s2sin" and host then
-		if not session.hosts[host] then session.hosts[host] = {}; end
-		session.hosts[host].authed = true;
-	else
-		return false;
-	end
-	session.log("debug", "connection %s->%s is now authenticated for %s", session.from_host, session.to_host, host);
-
-	if (session.type == "s2sout" and session.external_auth ~= "succeeded") or session.type == "s2sin" then
-		-- Stream either used dialback for authentication or is an incoming stream.
-		mark_connected(session);
-	end
-
-	return true;
-end
-
---- Helper to check that a session peer's certificate is valid
-local function check_cert_status(session)
-	local host = session.direction == "outgoing" and session.to_host or session.from_host
-	local conn = session.conn:socket()
-	local cert
-	if conn.getpeercertificate then
-		cert = conn:getpeercertificate()
-	end
-
-	return module:fire_event("s2s-check-certificate", { host = host, session = session, cert = cert });
-end
-
---- XMPP stream event handlers
-
-local stream_callbacks = { default_ns = "jabber:server" };
-
-function stream_callbacks.handlestanza(session, stanza)
-	stanza = session.filter("stanzas/in", stanza);
-	session.thread:run(stanza);
-end
-
-local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
-
-function stream_callbacks.streamopened(session, attr)
-	-- run _streamopened in async context
-	session.thread:run({ attr = attr });
-end
-
-function stream_callbacks._streamopened(session, attr)
-	session.version = tonumber(attr.version) or 0;
-
-	-- TODO: Rename session.secure to session.encrypted
-	if session.secure == false then
-		session.secure = true;
-		session.encrypted = true;
-
-		local sock = session.conn:socket();
-		if sock.info then
-			local info = sock:info();
-			(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
-			session.compressed = info.compression;
-		else
-			(session.log or log)("info", "Stream encrypted");
-			session.compressed = sock.compression and sock:compression(); --COMPAT mw/luasec-hg
-		end
-	end
-
-	if session.direction == "incoming" then
-		-- Send a reply stream header
-
-		-- Validate to/from
-		local to, from = nameprep(attr.to), nameprep(attr.from);
-		if not to and attr.to then -- COMPAT: Some servers do not reliably set 'to' (especially on stream restarts)
-			session:close({ condition = "improper-addressing", text = "Invalid 'to' address" });
-			return;
-		end
-		if not from and attr.from then -- COMPAT: Some servers do not reliably set 'from' (especially on stream restarts)
-			session:close({ condition = "improper-addressing", text = "Invalid 'from' address" });
-			return;
-		end
-
-		-- Set session.[from/to]_host if they have not been set already and if
-		-- this session isn't already authenticated
-		if session.type == "s2sin_unauthed" and from and not session.from_host then
-			session.from_host = from;
-		elseif from ~= session.from_host then
-			session:close({ condition = "improper-addressing", text = "New stream 'from' attribute does not match original" });
-			return;
-		end
-		if session.type == "s2sin_unauthed" and to and not session.to_host then
-			session.to_host = to;
-		elseif to ~= session.to_host then
-			session:close({ condition = "improper-addressing", text = "New stream 'to' attribute does not match original" });
-			return;
-		end
-
-		-- For convenience we'll put the sanitised values into these variables
-		to, from = session.to_host, session.from_host;
-
-		session.streamid = uuid_gen();
-		(session.log or log)("debug", "Incoming s2s received %s", st.stanza("stream:stream", attr):top_tag());
-		if to then
-			if not hosts[to] then
-				-- Attempting to connect to a host we don't serve
-				session:close({
-					condition = "host-unknown";
-					text = "This host does not serve "..to
-				});
-				return;
-			elseif not hosts[to].modules.s2s then
-				-- Attempting to connect to a host that disallows s2s
-				session:close({
-					condition = "policy-violation";
-					text = "Server-to-server communication is disabled for this host";
-				});
-				return;
-			end
-		end
-
-		if hosts[from] then
-			session:close({ condition = "undefined-condition", text = "Attempt to connect from a host we serve" });
-			return;
-		end
-
-		if session.secure and not session.cert_chain_status then
-			if check_cert_status(session) == false then
-				return;
-			end
-		end
-
-		session:open_stream(session.to_host, session.from_host)
-		session.notopen = nil;
-		if session.version >= 1.0 then
-			local features = st.stanza("stream:features");
-
-			if to then
-				hosts[to].events.fire_event("s2s-stream-features", { origin = session, features = features });
-			else
-				(session.log or log)("warn", "No 'to' on stream header from %s means we can't offer any features", from or session.ip or "unknown host");
-				fire_global_event("s2s-stream-features-legacy", { origin = session, features = features });
-			end
-
-			if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then
-				log("debug", "Sending stream features: %s", features);
-				session.sends2s(features);
-			else
-				(session.log or log)("warn", "No stream features to offer, giving up");
-				session:close({ condition = "undefined-condition", text = "No stream features to offer" });
-			end
-		end
-	elseif session.direction == "outgoing" then
-		session.notopen = nil;
-		if not attr.id then
-			log("warn", "Stream response did not give us a stream id!");
-			session:close({ condition = "undefined-condition", text = "Missing stream ID" });
-			return;
-		end
-		session.streamid = attr.id;
-
-		if session.secure and not session.cert_chain_status then
-			if check_cert_status(session) == false then
-				return;
-			end
-		end
-
-		-- Send unauthed buffer
-		-- (stanzas which are fine to send before dialback)
-		-- Note that this is *not* the stanza queue (which
-		-- we can only send if auth succeeds) :)
-		local send_buffer = session.send_buffer;
-		if send_buffer and #send_buffer > 0 then
-			log("debug", "Sending s2s send_buffer now...");
-			for i, data in ipairs(send_buffer) do
-				session.sends2s(tostring(data));
-				send_buffer[i] = nil;
-			end
-		end
-		session.send_buffer = nil;
-
-		-- If server is pre-1.0, don't wait for features, just do dialback
-		if session.version < 1.0 then
-			if not session.dialback_verifying then
-				hosts[session.from_host].events.fire_event("s2sout-authenticate-legacy", { origin = session });
-			else
-				mark_connected(session);
-			end
-		end
-	end
-end
-
-function stream_callbacks.streamclosed(session)
-	(session.log or log)("debug", "Received </stream:stream>");
-	session:close(false);
-end
-
-function stream_callbacks.error(session, error, data)
-	if error == "no-stream" then
-		session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
-		session:close("invalid-namespace");
-	elseif error == "parse-error" then
-		session.log("debug", "Server-to-server XML parse error: %s", error);
-		session:close("not-well-formed");
-	elseif error == "stream-error" then
-		local condition, text = "undefined-condition";
-		for child in data:childtags(nil, xmlns_xmpp_streams) do
-			if child.name ~= "text" then
-				condition = child.name;
-			else
-				text = child:get_text();
-			end
-			if condition ~= "undefined-condition" and text then
-				break;
-			end
-		end
-		text = condition .. (text and (" ("..text..")") or "");
-		session.log("info", "Session closed by remote with error: %s", text);
-		session:close(nil, text);
-	end
-end
-
-local listener = {};
-
---- Session methods
-local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'};
-local function session_close(session, reason, remote_reason)
-	local log = session.log or log;
-	if session.conn then
-		if session.notopen then
-			if session.direction == "incoming" then
-				session:open_stream(session.to_host, session.from_host);
-			else
-				session:open_stream(session.from_host, session.to_host);
-			end
-		end
-		if reason then -- nil == no err, initiated by us, false == initiated by remote
-			if type(reason) == "string" then -- assume stream error
-				log("debug", "Disconnecting %s[%s], <stream:error> is: %s", session.host or session.ip or "(unknown host)", session.type, reason);
-				session.sends2s(st.stanza("stream:error"):tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' }));
-			elseif type(reason) == "table" then
-				if reason.condition then
-					local stanza = st.stanza("stream:error"):tag(reason.condition, stream_xmlns_attr):up();
-					if reason.text then
-						stanza:tag("text", stream_xmlns_attr):text(reason.text):up();
-					end
-					if reason.extra then
-						stanza:add_child(reason.extra);
-					end
-					log("debug", "Disconnecting %s[%s], <stream:error> is: %s",
-					session.host or session.ip or "(unknown host)", session.type, stanza);
-					session.sends2s(stanza);
-				elseif reason.name then -- a stanza
-					log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s",
-						session.from_host or "(unknown host)", session.to_host or "(unknown host)",
-						session.type, reason);
-					session.sends2s(reason);
-				end
-			end
-		end
-
-		session.sends2s("</stream:stream>");
-		function session.sends2s() return false; end
-
-		-- luacheck: ignore 422/reason
-		-- FIXME reason should be managed in a place common to c2s, s2s, bosh, component etc
-		local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason;
-		session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper),
-			session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed");
-
-		-- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote
-		local conn = session.conn;
-		if reason == nil and not session.notopen and session.type == "s2sin" then
-			add_task(stream_close_timeout, function ()
-				if not session.destroyed then
-					session.log("warn", "Failed to receive a stream close response, closing connection anyway...");
-					s2s_destroy_session(session, reason);
-					conn:close();
-				end
-			end);
-		else
-			s2s_destroy_session(session, reason);
-			conn:close(); -- Close immediately, as this is an outgoing connection or is not authed
-		end
-	end
-end
-
-function session_stream_attrs(session, from, to, attr) -- luacheck: ignore 212/session
-	if not from or (hosts[from] and hosts[from].modules.dialback) then
-		attr["xmlns:db"] = 'jabber:server:dialback';
-	end
-	if not from then
-		attr.from = '';
-	end
-	if not to then
-		attr.to = '';
-	end
-end
-
--- Session initialization logic shared by incoming and outgoing
-local function initialize_session(session)
-	local stream = new_xmpp_stream(session, stream_callbacks, stanza_size_limit);
-
-	session.thread = runner(function (stanza)
-		if stanza.name == nil then
-			stream_callbacks._streamopened(session, stanza.attr);
-		else
-			core_process_stanza(session, stanza);
-		end
-	end, runner_callbacks, session);
-
-	local log = session.log or log;
-	session.stream = stream;
-
-	session.notopen = true;
-
-	function session.reset_stream()
-		session.notopen = true;
-		session.streamid = nil;
-		session.stream:reset();
-	end
-
-	session.stream_attrs = session_stream_attrs;
-
-	local filter = initialize_filters(session);
-	local conn = session.conn;
-	local w = conn.write;
-
-	function session.sends2s(t)
-		log("debug", "Sending[%s]: %s", session.type, 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 w(conn, t);
-			end
-		end
-	end
-
-	function session.data(data)
-		data = filter("bytes/in", data);
-		if data then
-			local ok, err = stream:feed(data);
-			if ok then return; end
-			log("debug", "Received invalid XML (%s) %d bytes: %s", tostring(err), #data, data:sub(1, 300):gsub("[\r\n]+", " "):gsub("[%z\1-\31]", "_"));
-			session:close("not-well-formed");
-		end
-	end
-
-	session.close = session_close;
-
-	local handlestanza = stream_callbacks.handlestanza;
-	function session.dispatch_stanza(session, stanza) -- luacheck: ignore 432/session
-		return handlestanza(session, stanza);
-	end
-
-	module:fire_event("s2s-created", { session = session });
-
-	add_task(connect_timeout, function ()
-		if session.type == "s2sin" or session.type == "s2sout" then
-			return; -- Ok, we're connected
-		elseif session.type == "s2s_destroyed" then
-			return; -- Session already destroyed
-		end
-		-- Not connected, need to close session and clean up
-		(session.log or log)("debug", "Destroying incomplete session %s->%s due to inactivity",
-		session.from_host or "(unknown)", session.to_host or "(unknown)");
-		session:close("connection-timeout");
-	end);
-end
-
-function runner_callbacks:ready()
-	self.data.log("debug", "Runner %s ready (%s)", self.thread, coroutine.status(self.thread));
-	self.data.conn:resume();
-end
-
-function runner_callbacks:waiting()
-	self.data.log("debug", "Runner %s waiting (%s)", self.thread, coroutine.status(self.thread));
-	self.data.conn:pause();
-end
-
-function runner_callbacks:error(err)
-	(self.data.log or log)("error", "Traceback[s2s]: %s", err);
-end
-
-function listener.onconnect(conn)
-	conn:setoption("keepalive", opt_keepalives);
-	local session = sessions[conn];
-	if not session then -- New incoming connection
-		session = s2s_new_incoming(conn);
-		sessions[conn] = session;
-		session.log("debug", "Incoming s2s connection");
-		initialize_session(session);
-	else -- Outgoing session connected
-		session:open_stream(session.from_host, session.to_host);
-	end
-	session.ip = conn:ip();
-end
-
-function listener.onincoming(conn, data)
-	local session = sessions[conn];
-	if session then
-		session.data(data);
-	end
-end
-
-function listener.onstatus(conn, status)
-	if status == "ssl-handshake-complete" then
-		local session = sessions[conn];
-		if session and session.direction == "outgoing" then
-			session.log("debug", "Sending stream header...");
-			session:open_stream(session.from_host, session.to_host);
-		end
-	end
-end
-
-function listener.ondisconnect(conn, err)
-	local session = sessions[conn];
-	if session then
-		sessions[conn] = nil;
-		if err and session.direction == "outgoing" and session.notopen then
-			(session.log or log)("debug", "s2s connection attempt failed: %s", err);
-			if s2sout.attempt_connection(session, err) then
-				return; -- Session lives for now
-			end
-		end
-		(session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed");
-		s2s_destroy_session(session, err);
-	end
-end
-
-function listener.onreadtimeout(conn)
-	local session = sessions[conn];
-	if session then
-		local host = session.host or session.to_host;
-		return (hosts[host] or prosody).events.fire_event("s2s-read-timeout", { session = session });
-	end
-end
-
-function listener.register_outgoing(conn, session)
-	sessions[conn] = session;
-	initialize_session(session);
-end
-
-function listener.ondetach(conn)
-	sessions[conn] = nil;
-end
-
-function check_auth_policy(event)
-	local host, session = event.host, event.session;
-	local must_secure = secure_auth;
-
-	if not must_secure and secure_domains[host] then
-		must_secure = true;
-	elseif must_secure and insecure_domains[host] then
-		must_secure = false;
-	end
-
-	if must_secure and (session.cert_chain_status ~= "valid" or session.cert_identity_status ~= "valid") then
-		module:log("warn", "Forbidding insecure connection to/from %s", host or session.ip or "(unknown host)");
-		if session.direction == "incoming" then
-			session:close({ condition = "not-authorized", text = "Your server's certificate is invalid, expired, or not trusted by "..session.to_host });
-		else -- Close outgoing connections without warning
-			session:close(false);
-		end
-		return false;
-	end
-end
-
-module:hook("s2s-check-certificate", check_auth_policy, -1);
-
-s2sout.set_listener(listener);
-
-module:hook("server-stopping", function(event)
-	local reason = event.reason;
-	for _, session in pairs(sessions) do
-		session:close{ condition = "system-shutdown", text = reason };
-	end
-end, -200);
-
-
-
-module:provides("net", {
-	name = "s2s";
-	listener = listener;
-	default_port = 5269;
-	encryption = "starttls";
-	multiplex = {
-		pattern = "^<.*:stream.*%sxmlns%s*=%s*(['\"])jabber:server%1.*>";
-	};
-});
-
--- a/plugins/mod_s2s/s2sout.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,349 +0,0 @@
--- Prosody IM
--- 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.
---
-
---- Module containing all the logic for connecting to a remote server
-
--- luacheck: ignore 432/err
-
-local portmanager = require "core.portmanager";
-local wrapclient = require "net.server".wrapclient;
-local initialize_filters = require "util.filters".initialize;
-local idna_to_ascii = require "util.encodings".idna.to_ascii;
-local new_ip = require "util.ip".new_ip;
-local rfc6724_dest = require "util.rfc6724".destination;
-local socket = require "socket";
-local adns = require "net.adns";
-local t_insert, t_sort, ipairs = table.insert, table.sort, ipairs;
-local local_addresses = require "util.net".local_addresses;
-
-local s2s_destroy_session = require "core.s2smanager".destroy_session;
-
-local default_mode = module:get_option("network_default_read_size", 4096);
-
-local log = module._log;
-
-local sources = {};
-local has_ipv4, has_ipv6;
-
-local dns_timeout = module:get_option_number("dns_timeout", 15);
-local resolvers = module:get_option_set("s2s_dns_resolvers")
-
-local s2sout = {};
-
-local s2s_listener;
-
-
-function s2sout.set_listener(listener)
-	s2s_listener = listener;
-end
-
-local function compare_srv_priorities(a,b)
-	return a.priority < b.priority or (a.priority == b.priority and a.weight > b.weight);
-end
-
-function s2sout.initiate_connection(host_session)
-	local log = host_session.log or log;
-
-	initialize_filters(host_session);
-	host_session.version = 1;
-
-	host_session.resolver = adns.resolver();
-	host_session.resolver._resolver:settimeout(dns_timeout);
-	if resolvers then
-		for resolver in resolvers do
-			host_session.resolver._resolver:addnameserver(resolver);
-		end
-	end
-
-	-- Kick the connection attempting machine into life
-	if not s2sout.attempt_connection(host_session) then
-		-- Intentionally not returning here, the
-		-- session is needed, connected or not
-		s2s_destroy_session(host_session);
-	end
-
-	if not host_session.sends2s then
-		-- A sends2s which buffers data (until the stream is opened)
-		-- note that data in this buffer will be sent before the stream is authed
-		-- and will not be ack'd in any way, successful or otherwise
-		local buffer;
-		function host_session.sends2s(data)
-			if not buffer then
-				buffer = {};
-				host_session.send_buffer = buffer;
-			end
-			log("debug", "Buffering data on unconnected s2sout to %s", host_session.to_host);
-			buffer[#buffer+1] = data;
-			log("debug", "Buffered item %d: %s", #buffer, data);
-		end
-	end
-end
-
-function s2sout.attempt_connection(host_session, err)
-	local to_host = host_session.to_host;
-	local connect_host, connect_port = to_host and idna_to_ascii(to_host), 5269;
-	local log = host_session.log or log;
-
-	if not connect_host then
-		return false;
-	end
-
-	if not err then -- This is our first attempt
-		log("debug", "First attempt to connect to %s, starting with SRV lookup...", to_host);
-		host_session.connecting = true;
-		host_session.resolver:lookup(function (answer)
-			local srv_hosts = { answer = answer };
-			host_session.srv_hosts = srv_hosts;
-			host_session.srv_choice = 0;
-			host_session.connecting = nil;
-			if answer and #answer > 0 then
-				log("debug", "%s has SRV records, handling...", to_host);
-				for _, record in ipairs(answer) do
-					t_insert(srv_hosts, record.srv);
-				end
-				if #srv_hosts == 1 and srv_hosts[1].target == "." then
-					log("debug", "%s does not provide a XMPP service", to_host);
-					s2s_destroy_session(host_session, err); -- Nothing to see here
-					return;
-				end
-				t_sort(srv_hosts, compare_srv_priorities);
-
-				local srv_choice = srv_hosts[1];
-				host_session.srv_choice = 1;
-				if srv_choice then
-					connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port;
-					log("debug", "Best record found, will connect to %s:%d", connect_host, connect_port);
-				end
-			else
-				log("debug", "%s has no SRV records, falling back to A/AAAA", to_host);
-			end
-			-- Try with SRV, or just the plain hostname if no SRV
-			local ok, err = s2sout.try_connect(host_session, connect_host, connect_port);
-			if not ok then
-				if not s2sout.attempt_connection(host_session, err) then
-					-- No more attempts will be made
-					s2s_destroy_session(host_session, err);
-				end
-			end
-		end, "_xmpp-server._tcp."..connect_host..".", "SRV");
-
-		return true; -- Attempt in progress
-	elseif host_session.ip_hosts then
-		return s2sout.try_connect(host_session, connect_host, connect_port, err);
-	elseif host_session.srv_hosts and #host_session.srv_hosts > host_session.srv_choice then -- Not our first attempt, and we also have SRV
-		host_session.srv_choice = host_session.srv_choice + 1;
-		local srv_choice = host_session.srv_hosts[host_session.srv_choice];
-		connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port;
-		host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", err, host_session.srv_choice, connect_host, connect_port);
-	else
-		host_session.log("info", "Failed in all attempts to connect to %s", host_session.to_host);
-		-- We're out of options
-		return false;
-	end
-
-	if not (connect_host and connect_port) then
-		-- Likely we couldn't resolve DNS
-		log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", connect_host, connect_port, to_host);
-		return false;
-	end
-
-	return s2sout.try_connect(host_session, connect_host, connect_port);
-end
-
-function s2sout.try_next_ip(host_session)
-	host_session.connecting = nil;
-	host_session.ip_choice = host_session.ip_choice + 1;
-	local ip = host_session.ip_hosts[host_session.ip_choice];
-	local ok, err= s2sout.make_connect(host_session, ip.ip, ip.port);
-	if not ok then
-		if not s2sout.attempt_connection(host_session, err or "closed") then
-			err = err and (": "..err) or "";
-			s2s_destroy_session(host_session, "Connection failed"..err);
-		end
-	end
-end
-
-function s2sout.try_connect(host_session, connect_host, connect_port, err)
-	host_session.connecting = true;
-	local log = host_session.log or log;
-
-	if not err then
-		local IPs = {};
-		host_session.ip_hosts = IPs;
-		-- luacheck: ignore 231/handle4 231/handle6
-		local handle4, handle6;
-		local have_other_result = not(has_ipv4) or not(has_ipv6) or false;
-
-		if has_ipv4 then
-			handle4 = host_session.resolver:lookup(function (reply, err)
-				handle4 = nil;
-
-				if reply and reply[#reply] and reply[#reply].a then
-					for _, ip in ipairs(reply) do
-						log("debug", "DNS reply for %s gives us %s", connect_host, ip.a);
-						IPs[#IPs+1] = new_ip(ip.a, "IPv4");
-					end
-				elseif err then
-					log("debug", "Error in DNS lookup: %s", err);
-				end
-
-				if have_other_result then
-					if #IPs > 0 then
-						rfc6724_dest(host_session.ip_hosts, sources);
-						for i = 1, #IPs do
-							IPs[i] = {ip = IPs[i], port = connect_port};
-						end
-						host_session.ip_choice = 0;
-						s2sout.try_next_ip(host_session);
-					else
-						log("debug", "DNS lookup failed to get a response for %s", connect_host);
-						host_session.ip_hosts = nil;
-						if not s2sout.attempt_connection(host_session, "name resolution failed") then -- Retry if we can
-							log("debug", "No other records to try for %s - destroying", host_session.to_host);
-							err = err and (": "..err) or "";
-							s2s_destroy_session(host_session, "DNS resolution failed"..err); -- End of the line, we can't
-						end
-					end
-				else
-					have_other_result = true;
-				end
-			end, connect_host, "A", "IN");
-		else
-			have_other_result = true;
-		end
-
-		if has_ipv6 then
-			handle6 = host_session.resolver:lookup(function (reply, err)
-				handle6 = nil;
-
-				if reply and reply[#reply] and reply[#reply].aaaa then
-					for _, ip in ipairs(reply) do
-						log("debug", "DNS reply for %s gives us %s", connect_host, ip.aaaa);
-						IPs[#IPs+1] = new_ip(ip.aaaa, "IPv6");
-					end
-				elseif err then
-					log("debug", "Error in DNS lookup: %s", err);
-				end
-
-				if have_other_result then
-					if #IPs > 0 then
-						rfc6724_dest(host_session.ip_hosts, sources);
-						for i = 1, #IPs do
-							IPs[i] = {ip = IPs[i], port = connect_port};
-						end
-						host_session.ip_choice = 0;
-						s2sout.try_next_ip(host_session);
-					else
-						log("debug", "DNS lookup failed to get a response for %s", connect_host);
-						host_session.ip_hosts = nil;
-						if not s2sout.attempt_connection(host_session, "name resolution failed") then -- Retry if we can
-							log("debug", "No other records to try for %s - destroying", host_session.to_host);
-							err = err and (": "..err) or "";
-							s2s_destroy_session(host_session, "DNS resolution failed"..err); -- End of the line, we can't
-						end
-					end
-				else
-					have_other_result = true;
-				end
-			end, connect_host, "AAAA", "IN");
-		else
-			have_other_result = true;
-		end
-		return true;
-	elseif host_session.ip_hosts and #host_session.ip_hosts > host_session.ip_choice then -- Not our first attempt, and we also have IPs left to try
-		s2sout.try_next_ip(host_session);
-	else
-		log("debug", "Out of IP addresses, trying next SRV record (if any)");
-		host_session.ip_hosts = nil;
-		if not s2sout.attempt_connection(host_session, "out of IP addresses") then -- Retry if we can
-			log("debug", "No other records to try for %s - destroying", host_session.to_host);
-			err = err and (": "..err) or "";
-			s2s_destroy_session(host_session, "Connecting failed"..err); -- End of the line, we can't
-			return false;
-		end
-	end
-
-	return true;
-end
-
-function s2sout.make_connect(host_session, connect_host, connect_port)
-	local log = host_session.log or log;
-	log("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port);
-
-	-- Reset secure flag in case this is another
-	-- connection attempt after a failed STARTTLS
-	host_session.secure = nil;
-	host_session.encrypted = nil;
-
-	local conn, handler;
-	local proto = connect_host.proto;
-	if proto == "IPv4" then
-		conn, handler = socket.tcp();
-	elseif proto == "IPv6" and socket.tcp6 then
-		conn, handler = socket.tcp6();
-	else
-		handler = "Unsupported protocol: "..tostring(proto);
-	end
-
-	if not conn then
-		log("warn", "Failed to create outgoing connection, system error: %s", handler);
-		return false, handler;
-	end
-
-	conn:settimeout(0);
-	local success, err = conn:connect(connect_host.addr, connect_port);
-	if not success and err ~= "timeout" then
-		log("warn", "s2s connect() to %s (%s:%d) failed: %s", host_session.to_host, connect_host.addr, connect_port, err);
-		return false, err;
-	end
-
-	conn = wrapclient(conn, connect_host.addr, connect_port, s2s_listener, default_mode);
-	host_session.conn = conn;
-
-	-- Register this outgoing connection so that xmppserver_listener knows about it
-	-- otherwise it will assume it is a new incoming connection
-	s2s_listener.register_outgoing(conn, host_session);
-
-	log("debug", "Connection attempt in progress...");
-	return true;
-end
-
-module:hook_global("service-added", function (event)
-	if event.name ~= "s2s" then return end
-
-	local s2s_sources = portmanager.get_active_services():get("s2s");
-	if not s2s_sources then
-		module:log("warn", "s2s not listening on any ports, outgoing connections may fail");
-		return;
-	end
-	for source, _ in pairs(s2s_sources) do
-		if source == "*" or source == "0.0.0.0" then
-			for _, addr in ipairs(local_addresses("ipv4", true)) do
-				sources[#sources + 1] = new_ip(addr, "IPv4");
-			end
-		elseif source == "::" then
-			for _, addr in ipairs(local_addresses("ipv6", true)) do
-				sources[#sources + 1] = new_ip(addr, "IPv6");
-			end
-		else
-			sources[#sources + 1] = new_ip(source, (source:find(":") and "IPv6") or "IPv4");
-		end
-	end
-	for i = 1,#sources do
-		if sources[i].proto == "IPv6" then
-			has_ipv6 = true;
-		elseif sources[i].proto == "IPv4" then
-			has_ipv4 = true;
-		end
-	end
-	if not (has_ipv4 or has_ipv6)  then
-		module:log("warn", "No local IPv4 or IPv6 addresses detected, outgoing connections may fail");
-	end
-end);
-
-return s2sout;
--- a/plugins/mod_s2s_auth_certs.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_s2s_auth_certs.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -4,6 +4,9 @@
 local NULL = {};
 local log = module._log;
 
+local measure_cert_statuses = module:metric("counter", "checked", "", "Certificate validation results",
+	{ "chain"; "identity" })
+
 module:hook("s2s-check-certificate", function(event)
 	local session, host, cert = event.session, event.host, event.cert;
 	local conn = session.conn:socket();
@@ -17,9 +20,6 @@
 	local chain_valid, errors;
 	if conn.getpeerverification then
 		chain_valid, errors = conn:getpeerverification();
-	elseif conn.getpeerchainvalid then -- COMPAT mw/luasec-hg
-		chain_valid, errors = conn:getpeerchainvalid();
-		errors = (not chain_valid) and { { errors } } or nil;
 	else
 		chain_valid, errors = false, { { "Chain verification not supported by this version of LuaSec" } };
 	end
@@ -30,6 +30,7 @@
 			log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", "))
 		end
 		session.cert_chain_status = "invalid";
+		session.cert_chain_errors = errors;
 	else
 		log("debug", "certificate chain validation result: valid");
 		session.cert_chain_status = "valid";
@@ -45,5 +46,6 @@
 			log("debug", "certificate identity validation result: %s", session.cert_identity_status);
 		end
 	end
+	measure_cert_statuses:with_labels(session.cert_chain_status or "unknown", session.cert_identity_status or "unknown"):add(1);
 end, 509);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_s2s_bidi.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,40 @@
+-- Prosody IM
+-- Copyright (C) 2019 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local xmlns_bidi_feature = "urn:xmpp:features:bidi"
+local xmlns_bidi = "urn:xmpp:bidi";
+
+local require_encryption = module:get_option_boolean("s2s_require_encryption", true);
+
+module:hook("s2s-stream-features", function(event)
+	local origin, features = event.origin, event.features;
+	if origin.type == "s2sin_unauthed" and (not require_encryption or origin.secure) then
+		features:tag("bidi", { xmlns = xmlns_bidi_feature }):up();
+	end
+end);
+
+module:hook_tag("http://etherx.jabber.org/streams", "features", function (session, stanza)
+	if session.type == "s2sout_unauthed" and (not require_encryption or session.secure) then
+		local bidi = stanza:get_child("bidi", xmlns_bidi_feature);
+		if bidi then
+			session.incoming = true;
+			session.log("debug", "Requesting bidirectional stream");
+			session.sends2s(st.stanza("bidi", { xmlns = xmlns_bidi }));
+		end
+	end
+end, 200);
+
+module:hook_tag("urn:xmpp:bidi", "bidi", function(session)
+	if session.type == "s2sin_unauthed" and (not require_encryption or session.secure) then
+		session.log("debug", "Requested bidirectional stream");
+		session.outgoing = true;
+		return true;
+	end
+end);
+
--- a/plugins/mod_saslauth.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_saslauth.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,11 +12,12 @@
 local sm_bind_resource = require "core.sessionmanager".bind_resource;
 local sm_make_authenticated = require "core.sessionmanager".make_authenticated;
 local base64 = require "util.encodings".base64;
+local set = require "util.set";
+local errors = require "util.error";
 
 local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler;
-local tostring = tostring;
 
-local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", false));
+local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true));
 local allow_unencrypted_plain_auth = module:get_option_boolean("allow_unencrypted_plain_auth", false)
 local insecure_mechanisms = module:get_option_set("insecure_sasl_mechanisms", allow_unencrypted_plain_auth and {} or {"PLAIN", "LOGIN"});
 local disabled_mechanisms = module:get_option_set("disable_sasl_mechanisms", { "DIGEST-MD5" });
@@ -51,7 +52,7 @@
 		module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg });
 		session.sasl_handler = session.sasl_handler:clean_clone();
 	elseif status == "success" then
-		local ok, err = sm_make_authenticated(session, session.sasl_handler.username);
+		local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.scope);
 		if ok then
 			module:fire_event("authentication-success", { session = session });
 			session.sasl_handler = nil;
@@ -70,7 +71,6 @@
 	local text = stanza[1];
 	if text then
 		text = base64.decode(text);
-		--log("debug", "AUTH: %s", text:gsub("[%z\001-\008\011\012\014-\031]", " "));
 		if not text then
 			session.sasl_handler = nil;
 			session.send(build_reply("failure", "incorrect-encoding"));
@@ -80,7 +80,6 @@
 	local status, ret, err_msg = session.sasl_handler:process(text);
 	status, ret, err_msg = handle_status(session, status, ret, err_msg);
 	local s = build_reply(status, ret, err_msg);
-	log("debug", "sasl reply: %s", tostring(s));
 	session.send(s);
 	return true;
 end
@@ -92,7 +91,7 @@
 	session:reset_stream();
 	session:open_stream(session.from_host, session.to_host);
 
-	module:fire_event("s2s-authenticated", { session = session, host = session.to_host });
+	module:fire_event("s2s-authenticated", { session = session, host = session.to_host, mechanism = "EXTERNAL" });
 	return true;
 end)
 
@@ -107,18 +106,27 @@
 			break;
 		end
 	end
-	if text and condition then
-		condition = condition .. ": " .. text;
-	end
-	module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, condition);
+	local err = errors.new({
+			-- TODO type = what?
+			text = text,
+			condition = condition,
+		}, {
+			session = session,
+			stanza = stanza,
+		});
+
+	module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, err);
 
 	session.external_auth = "failed"
-	session.external_auth_failure_reason = condition;
+	session.external_auth_failure_reason = err;
 end, 500)
 
 module:hook_tag(xmlns_sasl, "failure", function (session, stanza) -- luacheck: ignore 212/stanza
 	session.log("debug", "No fallback from SASL EXTERNAL failure, giving up");
-	session:close(nil, session.external_auth_failure_reason);
+	session:close(nil, session.external_auth_failure_reason, errors.new({
+				type = "wait", condition = "remote-server-timeout",
+				text = "Could not authenticate to remote server",
+		}, { session = session, sasl_failure = session.external_auth_failure_reason, }));
 	return true;
 end, 90)
 
@@ -184,7 +192,7 @@
 	session.external_auth = "succeeded";
 	session.sends2s(build_reply("success"));
 	module:log("info", "Accepting SASL EXTERNAL identity from %s", session.from_host);
-	module:fire_event("s2s-authenticated", { session = session, host = session.from_host });
+	module:fire_event("s2s-authenticated", { session = session, host = session.from_host, mechanism = mechanism });
 	session:reset_stream();
 	return true;
 end
@@ -251,7 +259,7 @@
 		local sasl_handler = usermanager_get_sasl_handler(module.host, origin)
 		origin.sasl_handler = sasl_handler;
 		if origin.encrypted then
-			-- check wether LuaSec has the nifty binding to the function needed for tls-unique
+			-- check whether LuaSec has the nifty binding to the function needed for tls-unique
 			-- FIXME: would be nice to have this check only once and not for every socket
 			if sasl_handler.add_cb_handler then
 				local socket = origin.conn:socket();
@@ -259,32 +267,67 @@
 				if info.protocol == "TLSv1.3" then
 					log("debug", "Channel binding 'tls-unique' undefined in context of TLS 1.3");
 				elseif socket.getpeerfinished and socket:getpeerfinished() then
+					log("debug", "Channel binding 'tls-unique' supported");
 					sasl_handler:add_cb_handler("tls-unique", tls_unique);
+				else
+					log("debug", "Channel binding 'tls-unique' not supported (by LuaSec?)");
 				end
 				sasl_handler["userdata"] = {
 					["tls-unique"] = socket;
 				};
+			else
+				log("debug", "Channel binding not supported by SASL handler");
 			end
 		end
 		local mechanisms = st.stanza("mechanisms", mechanisms_attr);
 		local sasl_mechanisms = sasl_handler:mechanisms()
+		local available_mechanisms = set.new();
 		for mechanism in pairs(sasl_mechanisms) do
-			if disabled_mechanisms:contains(mechanism) then
-				log("debug", "Not offering disabled mechanism %s", mechanism);
-			elseif not origin.secure and insecure_mechanisms:contains(mechanism) then
-				log("debug", "Not offering mechanism %s on insecure connection", mechanism);
-			else
-				log("debug", "Offering mechanism %s", mechanism);
+			available_mechanisms:add(mechanism);
+		end
+		log("debug", "SASL mechanisms supported by handler: %s", available_mechanisms);
+
+		local usable_mechanisms = available_mechanisms - disabled_mechanisms;
+
+		local available_disabled = set.intersection(available_mechanisms, disabled_mechanisms);
+		if not available_disabled:empty() then
+			log("debug", "Not offering disabled mechanisms: %s", available_disabled);
+		end
+
+		local available_insecure = set.intersection(available_mechanisms, insecure_mechanisms);
+		if not origin.secure and not available_insecure:empty() then
+			log("debug", "Session is not secure, not offering insecure mechanisms: %s", available_insecure);
+			usable_mechanisms = usable_mechanisms - insecure_mechanisms;
+		end
+
+		if not usable_mechanisms:empty() then
+			log("debug", "Offering usable mechanisms: %s", usable_mechanisms);
+			for mechanism in usable_mechanisms do
 				mechanisms:tag("mechanism"):text(mechanism):up();
 			end
+			features:add_child(mechanisms);
+			return;
 		end
-		if mechanisms[1] then
-			features:add_child(mechanisms);
-		elseif not next(sasl_mechanisms) then
-			log("warn", "No available SASL mechanisms, verify that the configured authentication module is working");
-		else
-			log("warn", "All available authentication mechanisms are either disabled or not suitable for an insecure connection");
+
+		local authmod = module:get_option_string("authentication", "internal_hashed");
+		if available_mechanisms:empty() then
+			log("warn", "No available SASL mechanisms, verify that the configured authentication module '%s' is loaded and configured correctly", authmod);
+			return;
 		end
+
+		if not origin.secure and not available_insecure:empty() then
+			if not available_disabled:empty() then
+				log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s) or disabled (%s)",
+					authmod, available_insecure, available_disabled);
+			else
+				log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s)",
+					authmod, available_insecure);
+			end
+		elseif not available_disabled:empty() then
+			log("warn", "All SASL mechanisms provided by authentication module '%s' are disabled (%s)",
+				authmod, available_disabled);
+		end
+
 	else
 		features:tag("bind", bind_attr):tag("required"):up():up();
 		features:tag("session", xmpp_session_attr):tag("optional"):up():up();
--- a/plugins/mod_scansion_record.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_scansion_record.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,7 +8,7 @@
 local dm = require "util.datamanager";
 local st = require "util.stanza";
 
-local record_id = id.medium():lower();
+local record_id = id.short():lower();
 local record_date = os.date("%Y%b%d"):lower();
 local header_file = dm.getpath(record_id, "scansion", record_date, "scs", true);
 local record_file = dm.getpath(record_id, "scansion", record_date, "log", true);
@@ -18,10 +18,12 @@
 
 local function record(string)
 	scan:write(string);
+	scan:flush();
 end
 
 local function record_header(string)
 	head:write(string);
+	head:flush();
 end
 
 local function record_object(class, name, props)
@@ -30,6 +32,7 @@
 		head:write(("\t%s: %s\n"):format(k, v));
 	end
 	head:write("\n");
+	head:flush();
 end
 
 local function record_event(session, event)
@@ -37,8 +40,7 @@
 end
 
 local function record_stanza(stanza, session, verb)
-	local flattened = tostring(stanza):gsub("><", ">\n\t<");
-	-- TODO Proper prettyprinting with indentation
+	local flattened = tostring(stanza:indent(2, "\t"));
 	record(session.scansion_id.." "..verb..":\n\t"..flattened.."\n\n");
 end
 
--- a/plugins/mod_server_contact_info.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_server_contact_info.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,6 +7,8 @@
 --
 
 local array = require "util.array";
+local jid = require "util.jid";
+local url = require "socket.url";
 
 -- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo
 local form_layout = require "util.dataforms".new({
@@ -16,6 +18,7 @@
 	{ name = "feedback", var = "feedback-addresses", type = "list-multi" },
 	{ name = "sales", var = "sales-addresses", type = "list-multi" },
 	{ name = "security", var = "security-addresses", type = "list-multi" },
+	{ name = "status", var = "status-addresses", type = "list-multi" },
 	{ name = "support", var = "support-addresses", type = "list-multi" },
 });
 
@@ -23,7 +26,7 @@
 local admins = module:get_option_inherited_set("admins", {});
 
 local contact_config = module:get_option("contact_info", {
-	admin = array.collect( admins / function(admin) return "xmpp:" .. admin; end);
+	admin = array.collect(admins / jid.prep / function(admin) return url.build({scheme = "xmpp"; path = admin}); end);
 });
 
 module:add_extension(form_layout:form(contact_config, "result"));
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_smacks.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,727 @@
+-- XEP-0198: Stream Management for Prosody IM
+--
+-- Copyright (C) 2010-2015 Matthew Wild
+-- Copyright (C) 2010 Waqas Hussain
+-- Copyright (C) 2012-2021 Kim Alvefur
+-- Copyright (C) 2012 Thijs Alkemade
+-- Copyright (C) 2014 Florian Zeitz
+-- Copyright (C) 2016-2020 Thilo Molitor
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local tonumber = tonumber;
+local tostring = tostring;
+local os_time = os.time;
+
+-- These metrics together allow to calculate an instantaneous
+-- "unacked stanzas" metric in the graphing frontend, without us having to
+-- iterate over all the queues.
+local tx_queued_stanzas = module:measure("tx_queued_stanzas", "counter");
+local tx_dropped_stanzas =  module:metric(
+	"histogram",
+	"tx_dropped_stanzas", "", "number of stanzas in a queue which got dropped",
+	{},
+	{buckets = {0, 1, 2, 4, 8, 16, 32}}
+):with_labels();
+local tx_acked_stanzas = module:metric(
+	"histogram",
+	"tx_acked_stanzas", "", "number of items acked per ack received",
+	{},
+	{buckets = {0, 1, 2, 4, 8, 16, 32}}
+):with_labels();
+
+-- number of session resumptions attempts where the session had expired
+local resumption_expired = module:measure("session_resumption_expired", "counter");
+local resumption_age = module:metric(
+	"histogram",
+	"resumption_age", "seconds", "time the session had been hibernating at the time of a resumption",
+	{},
+	{buckets = { 0, 1, 2, 5, 10, 30, 60, 120, 300, 600 }}
+):with_labels();
+local sessions_expired = module:measure("sessions_expired", "counter");
+local sessions_started = module:measure("sessions_started", "counter");
+
+
+local datetime = require "util.datetime";
+local add_filter = require "util.filters".add_filter;
+local jid = require "util.jid";
+local smqueue = require "util.smqueue";
+local st = require "util.stanza";
+local timer = require "util.timer";
+local new_id = require "util.id".short;
+local watchdog = require "util.watchdog";
+local it = require"util.iterators";
+
+local sessionmanager = require "core.sessionmanager";
+
+local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas";
+local xmlns_delay = "urn:xmpp:delay";
+local xmlns_mam2 = "urn:xmpp:mam:2";
+local xmlns_sm2 = "urn:xmpp:sm:2";
+local xmlns_sm3 = "urn:xmpp:sm:3";
+
+local sm2_attr = { xmlns = xmlns_sm2 };
+local sm3_attr = { xmlns = xmlns_sm3 };
+
+local queue_size = module:get_option_number("smacks_max_queue_size", 500);
+local resume_timeout = module:get_option_number("smacks_hibernation_time", 600);
+local s2s_smacks = module:get_option_boolean("smacks_enabled_s2s", true);
+local s2s_resend = module:get_option_boolean("smacks_s2s_resend", false);
+local max_unacked_stanzas = module:get_option_number("smacks_max_unacked_stanzas", 0);
+local max_inactive_unacked_stanzas = module:get_option_number("smacks_max_inactive_unacked_stanzas", 256);
+local delayed_ack_timeout = module:get_option_number("smacks_max_ack_delay", 30);
+local max_old_sessions = module:get_option_number("smacks_max_old_sessions", 10);
+
+local c2s_sessions = module:shared("/*/c2s/sessions");
+local local_sessions = prosody.hosts[module.host].sessions;
+
+local function format_h(h) if h then return string.format("%d", h) end end
+
+local all_old_sessions = module:open_store("smacks_h");
+local old_session_registry = module:open_store("smacks_h", "map");
+local session_registry = module:shared "/*/smacks/resumption-tokens"; -- > user@host/resumption-token --> resource
+
+local ack_errors = require"util.error".init("mod_smacks", xmlns_sm3, {
+	head = { condition = "undefined-condition"; text = "Client acknowledged more stanzas than sent by server" };
+	tail = { condition = "undefined-condition"; text = "Client acknowledged less stanzas than already acknowledged" };
+	pop = { condition = "internal-server-error"; text = "Something went wrong with Stream Management" };
+	overflow = { condition = "resource-constraint", text = "Too many unacked stanzas remaining, session can't be resumed" }
+});
+
+-- COMPAT note the use of compatibility wrapper in events (queue:table())
+
+local function ack_delayed(session, stanza)
+	-- fire event only if configured to do so and our session is not already hibernated or destroyed
+	if delayed_ack_timeout > 0 and session.awaiting_ack
+	and not session.hibernating and not session.destroyed then
+		session.log("debug", "Firing event 'smacks-ack-delayed', queue = %d",
+			session.outgoing_stanza_queue and session.outgoing_stanza_queue:count_unacked() or 0);
+		module:fire_event("smacks-ack-delayed", {origin = session, queue = session.outgoing_stanza_queue:table(), stanza = stanza});
+	end
+	session.delayed_ack_timer = nil;
+end
+
+local function can_do_smacks(session, advertise_only)
+	if session.smacks then return false, "unexpected-request", "Stream management is already enabled"; end
+
+	local session_type = session.type;
+	if session.username then
+		if not(advertise_only) and not(session.resource) then -- Fail unless we're only advertising sm
+			return false, "unexpected-request", "Client must bind a resource before enabling stream management";
+		end
+		return true;
+	elseif s2s_smacks and (session_type == "s2sin" or session_type == "s2sout") then
+		return true;
+	end
+	return false, "service-unavailable", "Stream management is not available for this stream";
+end
+
+module:hook("stream-features",
+		function (event)
+			if can_do_smacks(event.origin, true) then
+				event.features:tag("sm", sm2_attr):tag("optional"):up():up();
+				event.features:tag("sm", sm3_attr):tag("optional"):up():up();
+			end
+		end);
+
+module:hook("s2s-stream-features",
+		function (event)
+			if can_do_smacks(event.origin, true) then
+				event.features:tag("sm", sm2_attr):tag("optional"):up():up();
+				event.features:tag("sm", sm3_attr):tag("optional"):up():up();
+			end
+		end);
+
+local function should_ack(session, force)
+	if not session then return end -- shouldn't be possible
+	if session.destroyed then return end -- gone
+	if not session.smacks then return end -- not using
+	if session.hibernating then return end -- can't ack when asleep
+	if session.awaiting_ack then return end -- already waiting
+	if force then return force end
+	local queue = session.outgoing_stanza_queue;
+	local expected_h = queue:count_acked() + queue:count_unacked();
+	local max_unacked = max_unacked_stanzas;
+	if session.state == "inactive" then
+		max_unacked = max_inactive_unacked_stanzas;
+	end
+	-- this check of last_requested_h prevents ack-loops if misbehaving clients report wrong
+	-- stanza counts. it is set when an <r> is really sent (e.g. inside timer), preventing any
+	-- further requests until a higher h-value would be expected.
+	return queue:count_unacked() > max_unacked and expected_h ~= session.last_requested_h;
+end
+
+local function request_ack(session, reason)
+	local queue = session.outgoing_stanza_queue;
+	session.log("debug", "Sending <r> (inside timer, before send) from %s - #queue=%d", reason, queue:count_unacked());
+	session.awaiting_ack = true;
+	(session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks }))
+	if session.destroyed then return end -- sending something can trigger destruction
+	-- expected_h could be lower than this expression e.g. more stanzas added to the queue meanwhile)
+	session.last_requested_h = queue:count_acked() + queue:count_unacked();
+	session.log("debug", "Sending <r> (inside timer, after send) from %s - #queue=%d", reason, queue:count_unacked());
+	if not session.delayed_ack_timer then
+		session.delayed_ack_timer = timer.add_task(delayed_ack_timeout, function()
+			ack_delayed(session, nil); -- we don't know if this is the only new stanza in the queue
+		end);
+	end
+end
+
+local function request_ack_now_if_needed(session, force, reason)
+	if should_ack(session, force) then
+		request_ack(session, reason);
+	end
+end
+
+local function outgoing_stanza_filter(stanza, session)
+	-- XXX: Normally you wouldn't have to check the xmlns for a stanza as it's
+	-- supposed to be nil.
+	-- However, when using mod_smacks with mod_websocket, then mod_websocket's
+	-- stanzas/out filter can get called before this one and adds the xmlns.
+	if session.resending_unacked then return stanza end
+	if not session.smacks then return stanza end
+	local is_stanza = st.is_stanza(stanza) and
+		(not stanza.attr.xmlns or stanza.attr.xmlns == 'jabber:client')
+		and not stanza.name:find":";
+
+	if is_stanza then
+		local queue = session.outgoing_stanza_queue;
+		local cached_stanza = st.clone(stanza);
+
+		if cached_stanza.name ~= "iq" and cached_stanza:get_child("delay", xmlns_delay) == nil then
+			cached_stanza = cached_stanza:tag("delay", {
+				xmlns = xmlns_delay,
+				from = jid.bare(session.full_jid or session.host),
+				stamp = datetime.datetime()
+			});
+		end
+
+		queue:push(cached_stanza);
+		tx_queued_stanzas(1);
+
+		if session.hibernating then
+			session.log("debug", "hibernating since %s, stanza queued", datetime.datetime(session.hibernating));
+			-- FIXME queue implementation changed, anything depending on it being an array will break
+			module:fire_event("smacks-hibernation-stanza-queued", {origin = session, queue = queue:table(), stanza = cached_stanza});
+			return nil;
+		end
+	end
+	return stanza;
+end
+
+local function count_incoming_stanzas(stanza, session)
+	if not stanza.attr.xmlns then
+		session.handled_stanza_count = session.handled_stanza_count + 1;
+		session.log("debug", "Handled %d incoming stanzas", session.handled_stanza_count);
+	end
+	return stanza;
+end
+
+local function wrap_session_out(session, resume)
+	if not resume then
+		session.outgoing_stanza_queue = smqueue.new(queue_size);
+	end
+
+	add_filter(session, "stanzas/out", outgoing_stanza_filter, -999);
+
+	return session;
+end
+
+module:hook("pre-session-close", function(event)
+	local session = event.session;
+	if session.smacks == nil then return end
+	if session.resumption_token then
+		session.log("debug", "Revoking resumption token");
+		session_registry[jid.join(session.username, session.host, session.resumption_token)] = nil;
+		old_session_registry:set(session.username, session.resumption_token, nil);
+		session.resumption_token = nil;
+	else
+		session.log("debug", "Session not resumable");
+	end
+	if session.hibernating_watchdog then
+		session.log("debug", "Removing sleeping watchdog");
+		-- If the session is being replaced instead of resume, we don't want the
+		-- old session around to time out and cause trouble for the new session
+		session.hibernating_watchdog:cancel();
+		session.hibernating_watchdog = nil;
+	else
+		session.log("debug", "No watchdog set");
+	end
+	-- send out last ack as per revision 1.5.2 of XEP-0198
+	if session.smacks and session.conn and session.handled_stanza_count then
+		(session.sends2s or session.send)(st.stanza("a", {
+			xmlns = session.smacks;
+			h = format_h(session.handled_stanza_count);
+		}));
+	end
+end);
+
+local function wrap_session_in(session, resume)
+	if not resume then
+		sessions_started(1);
+		session.handled_stanza_count = 0;
+	end
+	add_filter(session, "stanzas/in", count_incoming_stanzas, 999);
+
+	return session;
+end
+
+local function wrap_session(session, resume)
+	wrap_session_out(session, resume);
+	wrap_session_in(session, resume);
+	return session;
+end
+
+function handle_enable(session, stanza, xmlns_sm)
+	local ok, err, err_text = can_do_smacks(session);
+	if not ok then
+		session.log("warn", "Failed to enable smacks: %s", err_text); -- TODO: XEP doesn't say we can send error text, should it?
+		(session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):tag(err, { xmlns = xmlns_errors}));
+		return true;
+	end
+
+	if session.username then
+		local old_sessions, err = all_old_sessions:get(session.username);
+		module:log("debug", "Old sessions: %q", old_sessions)
+		if old_sessions then
+			local keep, count = {}, 0;
+			for token, info in it.sorted_pairs(old_sessions, function(a, b)
+				return (old_sessions[a].t or 0) > (old_sessions[b].t or 0);
+			end) do
+				count = count + 1;
+				if count > max_old_sessions then break end
+				keep[token] = info;
+			end
+			all_old_sessions:set(session.username, keep);
+		elseif err then
+			module:log("error", "Unable to retrieve old resumption counters: %s", err);
+		end
+	end
+
+	module:log("debug", "Enabling stream management");
+	session.smacks = xmlns_sm;
+
+	wrap_session(session, false);
+
+	local resume_max;
+	local resume_token;
+	local resume = stanza.attr.resume;
+	if (resume == "true" or resume == "1") and session.username then
+		-- resumption on s2s is not currently supported
+		resume_token = new_id();
+		session_registry[jid.join(session.username, session.host, resume_token)] = session;
+		session.resumption_token = resume_token;
+		resume_max = tostring(resume_timeout);
+	end
+	(session.sends2s or session.send)(st.stanza("enabled", { xmlns = xmlns_sm, id = resume_token, resume = resume, max = resume_max }));
+	return true;
+end
+module:hook_tag(xmlns_sm2, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm2); end, 100);
+module:hook_tag(xmlns_sm3, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm3); end, 100);
+
+module:hook_tag("http://etherx.jabber.org/streams", "features",
+		function (session, stanza)
+			-- Needs to be done after flushing sendq since those aren't stored as
+			-- stanzas and counting them is weird.
+			-- TODO unify sendq and smqueue
+			timer.add_task(1e-6, function ()
+				if can_do_smacks(session) then
+					if stanza:get_child("sm", xmlns_sm3) then
+						session.sends2s(st.stanza("enable", sm3_attr));
+						session.smacks = xmlns_sm3;
+					elseif stanza:get_child("sm", xmlns_sm2) then
+						session.sends2s(st.stanza("enable", sm2_attr));
+						session.smacks = xmlns_sm2;
+					else
+						return;
+					end
+					wrap_session_out(session, false);
+				end
+			end);
+		end);
+
+function handle_enabled(session, stanza, xmlns_sm) -- luacheck: ignore 212/stanza
+	module:log("debug", "Enabling stream management");
+	session.smacks = xmlns_sm;
+
+	wrap_session_in(session, false);
+
+	-- FIXME Resume?
+
+	return true;
+end
+module:hook_tag(xmlns_sm2, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm2); end, 100);
+module:hook_tag(xmlns_sm3, "enabled", function (session, stanza) return handle_enabled(session, stanza, xmlns_sm3); end, 100);
+
+function handle_r(origin, stanza, xmlns_sm) -- luacheck: ignore 212/stanza
+	if not origin.smacks then
+		module:log("debug", "Received ack request from non-smack-enabled session");
+		return;
+	end
+	module:log("debug", "Received ack request, acking for %d", origin.handled_stanza_count);
+	-- Reply with <a>
+	(origin.sends2s or origin.send)(st.stanza("a", { xmlns = xmlns_sm, h = format_h(origin.handled_stanza_count) }));
+	-- piggyback our own ack request if needed (see request_ack_if_needed() for explanation of last_requested_h)
+	request_ack_now_if_needed(origin, false, "piggybacked by handle_r", nil);
+	return true;
+end
+module:hook_tag(xmlns_sm2, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm2); end);
+module:hook_tag(xmlns_sm3, "r", function (origin, stanza) return handle_r(origin, stanza, xmlns_sm3); end);
+
+function handle_a(origin, stanza)
+	if not origin.smacks then return; end
+	origin.awaiting_ack = nil;
+	if origin.awaiting_ack_timer then
+		timer.stop(origin.awaiting_ack_timer);
+		origin.awaiting_ack_timer = nil;
+	end
+	if origin.delayed_ack_timer then
+		timer.stop(origin.delayed_ack_timer)
+		origin.delayed_ack_timer = nil;
+	end
+	-- Remove handled stanzas from outgoing_stanza_queue
+	local h = tonumber(stanza.attr.h);
+	if not h then
+		origin:close{ condition = "invalid-xml"; text = "Missing or invalid 'h' attribute"; };
+		return;
+	end
+	local queue = origin.outgoing_stanza_queue;
+	local handled_stanza_count = h-queue:count_acked();
+	local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked
+	if err then
+		origin.log("warn", "The client says it handled %d new stanzas, but we sent %d :)",
+			handled_stanza_count, queue:count_unacked());
+		origin.log("debug", "Client h: %d, our h: %d", tonumber(stanza.attr.h), queue:count_acked());
+		for i, item in queue._queue:items() do
+			origin.log("debug", "Q item %d: %s", i, item);
+		end
+		origin:close(err);
+		return;
+	end
+	tx_acked_stanzas:sample(handled_stanza_count);
+
+	origin.log("debug", "#queue = %d (acked: %d)", queue:count_unacked(), handled_stanza_count);
+	request_ack_now_if_needed(origin, false, "handle_a", nil)
+	return true;
+end
+module:hook_tag(xmlns_sm2, "a", handle_a);
+module:hook_tag(xmlns_sm3, "a", handle_a);
+
+local function handle_unacked_stanzas(session)
+	local queue = session.outgoing_stanza_queue;
+	local unacked = queue:count_unacked()
+	if unacked > 0 then
+		tx_dropped_stanzas:sample(unacked);
+		session.smacks = false; -- Disable queueing
+		session.outgoing_stanza_queue = nil;
+		for stanza in queue._queue:consume() do
+			if not module:fire_event("delivery/failure", { session = session, stanza = stanza }) then
+				if stanza.attr.type ~= "error" and stanza.attr.from ~= session.full_jid then
+					local reply = st.error_reply(stanza, "cancel", "recipient-unavailable");
+					module:send(reply);
+				end
+			end
+		end
+	end
+end
+
+-- don't send delivery errors for messages which will be delivered by mam later on
+-- check if stanza was archived --> this will allow us to send back errors for stanzas not archived
+-- because the user configured the server to do so ("no-archive"-setting for one special contact for example)
+module:hook("delivery/failure", function(event)
+	local session, stanza = event.session, event.stanza;
+	-- Only deal with authenticated (c2s) sessions
+	if session.username then
+		if stanza.name == "message" and stanza.attr.xmlns == nil and
+				( stanza.attr.type == "chat" or ( stanza.attr.type or "normal" ) == "normal" ) then
+			-- don't store messages in offline store if they are mam results
+			local mam_result = stanza:get_child("result", xmlns_mam2);
+			if mam_result ~= nil then
+				return true; -- stanza already "handled", don't send an error and don't add it to offline storage
+			end
+			-- do nothing here for normal messages and don't send out "message delivery errors",
+			-- because messages are already in MAM at this point (no need to frighten users)
+			local stanza_id = stanza:get_child_with_attr("stanza-id", "urn:xmpp:sid:0", "by", jid.bare(session.full_jid));
+			stanza_id = stanza_id and stanza_id.attr.id;
+			if session.mam_requested and stanza_id ~= nil then
+				session.log("debug", "mod_smacks delivery/failure returning true for mam-handled stanza: mam-archive-id=%s", tostring(stanza_id));
+				return true; -- stanza handled, don't send an error
+			end
+			-- store message in offline store, if this client does not use mam *and* was the last client online
+			local sessions = local_sessions[session.username] and local_sessions[session.username].sessions or nil;
+			if sessions and next(sessions) == session.resource and next(sessions, session.resource) == nil then
+				local ok = module:fire_event("message/offline/handle", { origin = session, username = session.username, stanza = stanza });
+				session.log("debug", "mod_smacks delivery/failure returning %s for offline-handled stanza", tostring(ok));
+				return ok; -- if stanza was handled, don't send an error
+			end
+		end
+	end
+end);
+
+module:hook("pre-resource-unbind", function (event)
+	local session = event.session;
+	if not session.smacks then return end
+	if not session.resumption_token then
+		local queue = session.outgoing_stanza_queue;
+		if queue:count_unacked() > 0 then
+			session.log("debug", "Destroying session with %d unacked stanzas", queue:count_unacked());
+			handle_unacked_stanzas(session);
+		end
+		return
+	end
+	if session.hibernating then return end
+
+	session.hibernating = os_time();
+	session.hibernating_watchdog = watchdog.new(resume_timeout, function()
+		session.log("debug", "mod_smacks hibernation timeout reached...");
+		if session.destroyed then
+			session.log("debug", "The session has already been destroyed");
+			return
+		elseif not session.resumption_token then
+			-- This should normally not happen, the watchdog should be canceled from session:close()
+			session.log("debug", "The session has already been resumed or replaced");
+			return
+		end
+
+		session.log("debug", "Destroying session for hibernating too long");
+		session_registry[jid.join(session.username, session.host, session.resumption_token)] = nil;
+		old_session_registry:set(session.username, session.resumption_token,
+			{ h = session.handled_stanza_count; t = os.time() });
+		session.resumption_token = nil;
+		session.resending_unacked = true; -- stop outgoing_stanza_filter from re-queueing anything anymore
+		sessionmanager.destroy_session(session, "Hibernating too long");
+		sessions_expired(1);
+	end);
+	if session.conn then
+		local conn = session.conn;
+		c2s_sessions[conn] = nil;
+		session.conn = nil;
+		conn:close();
+	end
+	module:fire_event("smacks-hibernation-start", { origin = session; queue = session.outgoing_stanza_queue:table() });
+	return true; -- Postpone destruction for now
+end);
+
+local function handle_s2s_destroyed(event)
+	local session = event.session;
+	local queue = session.outgoing_stanza_queue;
+	if queue and queue:count_unacked() > 0 then
+		session.log("warn", "Destroying session with %d unacked stanzas", queue:count_unacked());
+		if s2s_resend then
+			for stanza in queue:consume() do
+				module:send(stanza);
+			end
+			session.outgoing_stanza_queue = nil;
+		else
+			handle_unacked_stanzas(session);
+		end
+	end
+end
+
+module:hook("s2sout-destroyed", handle_s2s_destroyed);
+module:hook("s2sin-destroyed", handle_s2s_destroyed);
+
+local function get_session_id(session)
+	return session.id or (tostring(session):match("[a-f0-9]+$"));
+end
+
+function handle_resume(session, stanza, xmlns_sm)
+	if session.full_jid then
+		session.log("warn", "Tried to resume after resource binding");
+		session.send(st.stanza("failed", { xmlns = xmlns_sm })
+			:tag("unexpected-request", { xmlns = xmlns_errors })
+		);
+		return true;
+	end
+
+	local id = stanza.attr.previd;
+	local original_session = session_registry[jid.join(session.username, session.host, id)];
+	if not original_session then
+		local old_session = old_session_registry:get(session.username, id);
+		if old_session then
+			session.log("debug", "Tried to resume old expired session with id %s", id);
+			session.send(st.stanza("failed", { xmlns = xmlns_sm, h = format_h(old_session.h) })
+				:tag("item-not-found", { xmlns = xmlns_errors })
+			);
+			old_session_registry:set(session.username, id, nil);
+			resumption_expired(1);
+		else
+			session.log("debug", "Tried to resume non-existent session with id %s", id);
+			session.send(st.stanza("failed", { xmlns = xmlns_sm })
+				:tag("item-not-found", { xmlns = xmlns_errors })
+			);
+		end;
+	else
+		if original_session.hibernating_watchdog then
+			original_session.log("debug", "Letting the watchdog go");
+			original_session.hibernating_watchdog:cancel();
+			original_session.hibernating_watchdog = nil;
+		elseif session.hibernating then
+			original_session.log("error", "Hibernating session has no watchdog!")
+		end
+		-- zero age = was not hibernating yet
+		local age = 0;
+		if original_session.hibernating then
+			local now = os_time();
+			age = now - original_session.hibernating;
+		end
+		session.log("debug", "mod_smacks resuming existing session %s...", get_session_id(original_session));
+		original_session.log("debug", "mod_smacks session resumed from %s...", get_session_id(session));
+		-- TODO: All this should move to sessionmanager (e.g. session:replace(new_session))
+		if original_session.conn then
+			original_session.log("debug", "mod_smacks closing an old connection for this session");
+			local conn = original_session.conn;
+			c2s_sessions[conn] = nil;
+			conn:close();
+		end
+
+		local migrated_session_log = session.log;
+		original_session.ip = session.ip;
+		original_session.conn = session.conn;
+		original_session.rawsend = session.rawsend;
+		original_session.rawsend.session = original_session;
+		original_session.rawsend.conn = original_session.conn;
+		original_session.send = session.send;
+		original_session.send.session = original_session;
+		original_session.close = session.close;
+		original_session.filter = session.filter;
+		original_session.filter.session = original_session;
+		original_session.filters = session.filters;
+		original_session.send.filter = original_session.filter;
+		original_session.stream = session.stream;
+		original_session.secure = session.secure;
+		original_session.hibernating = nil;
+		original_session.resumption_counter = (original_session.resumption_counter or 0) + 1;
+		session.log = original_session.log;
+		session.type = original_session.type;
+		wrap_session(original_session, true);
+		-- Inform xmppstream of the new session (passed to its callbacks)
+		original_session.stream:set_session(original_session);
+		-- Similar for connlisteners
+		c2s_sessions[session.conn] = original_session;
+
+		local queue = original_session.outgoing_stanza_queue;
+		local h = tonumber(stanza.attr.h);
+
+		original_session.log("debug", "Pre-resumption #queue = %d", queue:count_unacked())
+		local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked
+
+		if not err and not queue:resumable() then
+			err = ack_errors.new("overflow");
+		end
+
+		if err or not queue:resumable() then
+			original_session.send(st.stanza("failed",
+				{ xmlns = xmlns_sm; h = format_h(original_session.handled_stanza_count); previd = id }));
+			original_session:close(err);
+			return false;
+		end
+
+		original_session.send(st.stanza("resumed", { xmlns = xmlns_sm,
+			h = format_h(original_session.handled_stanza_count), previd = id }));
+
+		-- Ok, we need to re-send any stanzas that the client didn't see
+		-- ...they are what is now left in the outgoing stanza queue
+		-- We have to use the send of "session" because we don't want to add our resent stanzas
+		-- to the outgoing queue again
+
+		session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", queue:count_unacked());
+		-- FIXME Which session is it that the queue filter sees?
+		session.resending_unacked = true;
+		original_session.resending_unacked = true;
+		for _, queued_stanza in queue:resume() do
+			session.send(queued_stanza);
+		end
+		session.resending_unacked = nil;
+		original_session.resending_unacked = nil;
+		session.log("debug", "all stanzas resent, now disabling send() in this migrated session, #queue = %d", queue:count_unacked());
+		function session.send(stanza) -- luacheck: ignore 432
+			migrated_session_log("error", "Tried to send stanza on old session migrated by smacks resume (maybe there is a bug?): %s", tostring(stanza));
+			return false;
+		end
+		module:fire_event("smacks-hibernation-end", {origin = session, resumed = original_session, queue = queue:table()});
+		original_session.awaiting_ack = nil; -- Don't wait for acks from before the resumption
+		request_ack_now_if_needed(original_session, true, "handle_resume", nil);
+		resumption_age:sample(age);
+	end
+	return true;
+end
+module:hook_tag(xmlns_sm2, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm2); end);
+module:hook_tag(xmlns_sm3, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm3); end);
+
+-- Events when it's sensible to request an ack
+-- Could experiment with forcing (ignoring max_unacked) <r>, but when and why?
+local request_ack_events = {
+	["csi-client-active"] = true;
+	["csi-flushing"] = false;
+	["c2s-pre-ondrain"] = false;
+	["s2s-pre-ondrain"] = false;
+};
+
+for event_name, force in pairs(request_ack_events) do
+	module:hook(event_name, function(event)
+		local session = event.session or event.origin;
+		request_ack_now_if_needed(session, force, event_name);
+	end);
+end
+
+local function handle_read_timeout(event)
+	local session = event.session;
+	if session.smacks then
+		if session.awaiting_ack then
+			if session.awaiting_ack_timer then
+				timer.stop(session.awaiting_ack_timer);
+				session.awaiting_ack_timer = nil;
+			end
+			if session.delayed_ack_timer then
+				timer.stop(session.delayed_ack_timer);
+				session.delayed_ack_timer = nil;
+			end
+			return false; -- Kick the session
+		end
+		request_ack_now_if_needed(session, true, "read timeout");
+		return true;
+	end
+end
+
+module:hook("s2s-read-timeout", handle_read_timeout);
+module:hook("c2s-read-timeout", handle_read_timeout);
+
+module:hook_global("server-stopping", function(event)
+	if not local_sessions then
+		-- not a VirtualHost, no user sessions
+		return
+	end
+	local reason = event.reason;
+	-- Close smacks-enabled sessions ourselves instead of letting mod_c2s close
+	-- it, which invalidates the smacks session. This allows preserving the
+	-- counter value, so it can be communicated to the client when it tries to
+	-- resume the lost session after a restart.
+	for _, user in pairs(local_sessions) do
+		for _, session in pairs(user.sessions) do
+			if session.resumption_token then
+				if old_session_registry:set(session.username, session.resumption_token,
+					{ h = session.handled_stanza_count; t = os.time() }) then
+					session.resumption_token = nil;
+
+					-- Deal with unacked stanzas
+					if session.outgoing_stanza_queue then
+						handle_unacked_stanzas(session);
+					end
+
+					if session.conn then
+						session.conn:close()
+						session.conn = nil;
+						-- Now when mod_c2s gets here, it will immediately destroy the
+						-- session since it is unconnected.
+					end
+
+					-- And make sure nobody tries to send anything
+					session:close{ condition = "system-shutdown", text = reason };
+				end
+			end
+		end
+	end
+end, -90);
--- a/plugins/mod_stanza_debug.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_stanza_debug.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,18 +1,17 @@
 module:set_global();
 
-local tostring = tostring;
 local filters = require "util.filters";
 
 local function log_send(t, session)
 	if t and t ~= "" and t ~= " " then
-		session.log("debug", "SEND: %s", tostring(t));
+		session.log("debug", "SEND: %s", t);
 	end
 	return t;
 end
 
 local function log_recv(t, session)
 	if t and t ~= "" and t ~= " " then
-		session.log("debug", "RECV: %s", tostring(t));
+		session.log("debug", "RECV: %s", t);
 	end
 	return t;
 end
--- a/plugins/mod_storage_internal.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_storage_internal.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,12 +1,18 @@
+local cache = require "util.cache";
 local datamanager = require "core.storagemanager".olddm;
 local array = require "util.array";
 local datetime = require "util.datetime";
 local st = require "util.stanza";
 local now = require "util.time".now;
 local id = require "util.id".medium;
+local jid_join = require "util.jid".join;
+local set = require "util.set";
 
 local host = module.host;
 
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", 10000);
+local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000));
+
 local driver = {};
 
 function driver:open(store, typ)
@@ -43,6 +49,14 @@
 local archive = {};
 driver.archive = { __index = archive };
 
+archive.caps = {
+	total = true;
+	quota = archive_item_limit;
+	truncate = true;
+	full_id_range = true;
+	ids = true;
+};
+
 function archive:append(username, key, value, when, with)
 	when = when or now();
 	if not st.is_stanza(value) then
@@ -52,30 +66,58 @@
 	value.when = when;
 	value.with = with;
 	value.attr.stamp = datetime.datetime(when);
-	value.attr.stamp_legacy = datetime.legacy(when);
+
+	local cache_key = jid_join(username, host, self.store);
+	local item_count = archive_item_count_cache:get(cache_key);
 
 	if key then
 		local items, err = datamanager.list_load(username, host, self.store);
 		if not items and err then return items, err; end
+
+		-- Check the quota
+		item_count = items and #items or 0;
+		archive_item_count_cache:set(cache_key, item_count);
+		if item_count >= archive_item_limit then
+			module:log("debug", "%s reached or over quota, not adding to store", username);
+			return nil, "quota-limit";
+		end
+
 		if items then
+			-- Filter out any item with the same key as the one being added
 			items = array(items);
 			items:filter(function (item)
 				return item.key ~= key;
 			end);
+
 			value.key = key;
 			items:push(value);
 			local ok, err = datamanager.list_store(username, host, self.store, items);
 			if not ok then return ok, err; end
+			archive_item_count_cache:set(cache_key, #items);
 			return key;
 		end
 	else
+		if not item_count then -- Item count not cached?
+			-- We need to load the list to get the number of items currently stored
+			local items, err = datamanager.list_load(username, host, self.store);
+			if not items and err then return items, err; end
+			item_count = items and #items or 0;
+			archive_item_count_cache:set(cache_key, item_count);
+		end
+		if item_count >= archive_item_limit then
+			module:log("debug", "%s reached or over quota, not adding to store", username);
+			return nil, "quota-limit";
+		end
 		key = id();
 	end
 
+	module:log("debug", "%s has %d items out of %d limit in store %s", username, item_count, archive_item_limit, self.store);
+
 	value.key = key;
 
 	local ok, err = datamanager.list_append(username, host, self.store, value);
 	if not ok then return ok, err; end
+	archive_item_count_cache:set(cache_key, item_count+1);
 	return key;
 end
 
@@ -84,12 +126,18 @@
 	if not items then
 		if err then
 			return items, err;
-		else
-			return function () end, 0;
+		elseif query then
+			if query.before or query.after then
+				return nil, "item-not-found";
+			end
+			if query.total then
+				return function () end, 0;
+			end
 		end
+		return function () end;
 	end
-	local count = #items;
-	local i = 0;
+	local count = nil;
+	local i, last_key = 0;
 	if query then
 		items = array(items);
 		if query.key then
@@ -97,6 +145,12 @@
 				return item.key == query.key;
 			end);
 		end
+		if query.ids then
+			local ids = set.new(query.ids);
+			items:filter(function (item)
+				return ids:contains(item.key);
+			end);
+		end
 		if query.with then
 			items:filter(function (item)
 				return item.with == query.with;
@@ -114,24 +168,40 @@
 				return when <= query["end"];
 			end);
 		end
-		count = #items;
+		if query.total then
+			count = #items;
+		end
 		if query.reverse then
 			items:reverse();
 			if query.before then
-				for j = 1, count do
+				local found = false;
+				for j = 1, #items do
 					if (items[j].key or tostring(j)) == query.before then
+						found = true;
 						i = j;
 						break;
 					end
 				end
+				if not found then
+					return nil, "item-not-found";
+				end
 			end
+			last_key = query.after;
 		elseif query.after then
-			for j = 1, count do
+			local found = false;
+			for j = 1, #items do
 				if (items[j].key or tostring(j)) == query.after then
+					found = true;
 					i = j;
 					break;
 				end
 			end
+			if not found then
+				return nil, "item-not-found";
+			end
+			last_key = query.before;
+		elseif query.before then
+			last_key = query.before;
 		end
 		if query.limit and #items - i > query.limit then
 			items[i+query.limit+1] = nil;
@@ -140,26 +210,97 @@
 	return function ()
 		i = i + 1;
 		local item = items[i];
-		if not item then return; end
+		if not item or (last_key and item.key == last_key) then
+			return;
+		end
 		local key = item.key or tostring(i);
 		local when = item.when or datetime.parse(item.attr.stamp);
 		local with = item.with;
 		item.key, item.when, item.with = nil, nil, nil;
 		item.attr.stamp = nil;
+		-- COMPAT Stored data may still contain legacy XEP-0091 timestamp
 		item.attr.stamp_legacy = nil;
 		item = st.deserialize(item);
 		return key, item, when, with;
 	end, count;
 end
 
+function archive:get(username, wanted_key)
+	local iter, err = self:find(username, { key = wanted_key })
+	if not iter then return iter, err; end
+	for key, stanza, when, with in iter do
+		if key == wanted_key then
+			return stanza, when, with;
+		end
+	end
+	return nil, "item-not-found";
+end
+
+function archive:set(username, key, new_value, new_when, new_with)
+	local items, err = datamanager.list_load(username, host, self.store);
+	if not items then
+		if err then
+			return items, err;
+		else
+			return nil, "item-not-found";
+		end
+	end
+
+	for i = 1, #items do
+		local old_item = items[i];
+		if old_item.key == key then
+			local item = st.preserialize(st.clone(new_value));
+
+			local when = new_when or old_item.when or datetime.parse(old_item.attr.stamp);
+			item.key = key;
+			item.when = when;
+			item.with = new_with or old_item.with;
+			item.attr.stamp = datetime.datetime(when);
+			items[i] = item;
+			return datamanager.list_store(username, host, self.store, items);
+		end
+	end
+
+	return nil, "item-not-found";
+end
+
 function archive:dates(username)
 	local items, err = datamanager.list_load(username, host, self.store);
 	if not items then return items, err; end
 	return array(items):pluck("when"):map(datetime.date):unique();
 end
 
+function archive:summary(username, query)
+	local iter, err = self:find(username, query)
+	if not iter then return iter, err; end
+	local counts = {};
+	local earliest = {};
+	local latest = {};
+	local body = {};
+	for _, stanza, when, with in iter do
+		counts[with] = (counts[with] or 0) + 1;
+		if earliest[with] == nil then
+			earliest[with] = when;
+		end
+		latest[with] = when;
+		body[with] = stanza:get_child_text("body") or body[with];
+	end
+	return {
+		counts = counts;
+		earliest = earliest;
+		latest = latest;
+		body = body;
+	};
+end
+
+function archive:users()
+	return datamanager.users(host, self.store, "list");
+end
+
 function archive:delete(username, query)
+	local cache_key = jid_join(username, host, self.store);
 	if not query or next(query) == nil then
+		archive_item_count_cache:set(cache_key, nil);
 		return datamanager.list_store(username, host, self.store, nil);
 	end
 	local items, err = datamanager.list_load(username, host, self.store);
@@ -167,6 +308,7 @@
 		if err then
 			return items, err;
 		end
+		archive_item_count_cache:set(cache_key, 0);
 		-- Store is empty
 		return 0;
 	end
@@ -216,6 +358,7 @@
 	end
 	local ok, err = datamanager.list_store(username, host, self.store, items);
 	if not ok then return ok, err; end
+	archive_item_count_cache:set(cache_key, #items);
 	return count;
 end
 
--- a/plugins/mod_storage_memory.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_storage_memory.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -4,10 +4,13 @@
 local st = require "util.stanza";
 local is_stanza = st.is_stanza or function (s) return getmetatable(s) == st.stanza_mt end
 local new_id = require "util.id".medium;
+local set = require "util.set";
 
 local auto_purge_enabled = module:get_option_boolean("storage_memory_temporary", false);
 local auto_purge_stores = module:get_option_set("storage_memory_temporary_stores", {});
 
+local archive_item_limit = module:get_option_number("storage_archive_item_limit", 1000);
+
 local memory = setmetatable({}, {
 	__index = function(t, k)
 		local store = module:shared(k)
@@ -51,6 +54,14 @@
 
 archive_store.users = _users;
 
+archive_store.caps = {
+	total = true;
+	quota = archive_item_limit;
+	truncate = true;
+	full_id_range = true;
+	ids = true;
+};
+
 function archive_store:append(username, key, value, when, with)
 	if is_stanza(value) then
 		value = st.preserialize(value);
@@ -70,6 +81,8 @@
 	end
 	if a[key] then
 		table.remove(a, a[key]);
+	elseif #a >= archive_item_limit then
+		return nil, "quota-limit";
 	end
 	local i = #a+1;
 	a[i] = v;
@@ -80,10 +93,18 @@
 function archive_store:find(username, query)
 	local items = self.store[username or NULL];
 	if not items then
-		return function () end, 0;
+		if query then
+			if query.before or query.after then
+				return nil, "item-not-found";
+			end
+			if query.total then
+				return function () end, 0;
+			end
+		end
+		return function () end;
 	end
-	local count = #items;
-	local i = 0;
+	local count = nil;
+	local i, last_key = 0;
 	if query then
 		items = array():append(items);
 		if query.key then
@@ -91,6 +112,12 @@
 				return item.key == query.key;
 			end);
 		end
+		if query.ids then
+			local ids = set.new(query.ids);
+			items:filter(function (item)
+				return ids:contains(item.key);
+			end);
+		end
 		if query.with then
 			items:filter(function (item)
 				return item.with == query.with;
@@ -106,24 +133,40 @@
 				return item.when <= query["end"];
 			end);
 		end
-		count = #items;
+		if query.total then
+			count = #items;
+		end
 		if query.reverse then
 			items:reverse();
 			if query.before then
-				for j = 1, count do
+				local found = false;
+				for j = 1, #items do
 					if (items[j].key or tostring(j)) == query.before then
+						found = true;
 						i = j;
 						break;
 					end
 				end
+				if not found then
+					return nil, "item-not-found";
+				end
 			end
+			last_key = query.after;
 		elseif query.after then
-			for j = 1, count do
+			local found = false;
+			for j = 1, #items do
 				if (items[j].key or tostring(j)) == query.after then
+					found = true;
 					i = j;
 					break;
 				end
 			end
+			if not found then
+				return nil, "item-not-found";
+			end
+			last_key = query.before;
+		elseif query.before then
+			last_key = query.before;
 		end
 		if query.limit and #items - i > query.limit then
 			items[i+query.limit+1] = nil;
@@ -132,11 +175,62 @@
 	return function ()
 		i = i + 1;
 		local item = items[i];
-		if not item then return; end
+		if not item or (last_key and item.key == last_key) then return; end
 		return item.key, item.value(), item.when, item.with;
 	end, count;
 end
 
+function archive_store:get(username, wanted_key)
+	local items = self.store[username or NULL];
+	if not items then return nil, "item-not-found"; end
+	local i = items[wanted_key];
+	if not i then return nil, "item-not-found"; end
+	local item = items[i];
+	return item.value(), item.when, item.with;
+end
+
+function archive_store:set(username, wanted_key, new_value, new_when, new_with)
+	local items = self.store[username or NULL];
+	if not items then return nil, "item-not-found"; end
+	local i = items[wanted_key];
+	if not i then return nil, "item-not-found"; end
+	local item = items[i];
+
+	if is_stanza(new_value) then
+		new_value = st.preserialize(new_value);
+		item.value = envload("return xml"..serialize(new_value), "=(stanza)", { xml = st.deserialize })
+	else
+		item.value = envload("return "..serialize(new_value), "=(data)", {});
+	end
+	if new_when then
+		item.when = new_when;
+	end
+	if new_with then
+		item.with = new_when;
+	end
+	return true;
+end
+
+function archive_store:summary(username, query)
+	local iter, err = self:find(username, query)
+	if not iter then return iter, err; end
+	local counts = {};
+	local earliest = {};
+	local latest = {};
+	for _, _, when, with in iter do
+		counts[with] = (counts[with] or 0) + 1;
+		if earliest[with] == nil then
+			earliest[with] = when;
+		end
+		latest[with] = when;
+	end
+	return {
+		counts = counts;
+		earliest = earliest;
+		latest = latest;
+	};
+end
+
 
 function archive_store:delete(username, query)
 	if not query or next(query) == nil then
--- a/plugins/mod_storage_sql.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_storage_sql.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,17 +1,19 @@
 
 -- luacheck: ignore 212/self
 
+local cache = require "util.cache";
 local json = require "util.json";
 local sql = require "util.sql";
 local xml_parse = require "util.xml".parse;
 local uuid = require "util.uuid";
 local resolve_relative_path = require "util.paths".resolve_relative_path;
+local jid_join = require "util.jid".join;
 
 local is_stanza = require"util.stanza".is_stanza;
 local t_concat = table.concat;
 
 local noop = function() end
-local unpack = table.unpack or unpack;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
 local function iterator(result)
 	return function(result_)
 		local row = result_();
@@ -148,7 +150,13 @@
 
 --- Archive store API
 
--- luacheck: ignore 512 431/user 431/store
+local archive_item_limit = module:get_option_number("storage_archive_item_limit");
+local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000));
+
+local item_count_cache_hit = module:measure("item_count_cache_hit", "rate");
+local item_count_cache_miss = module:measure("item_count_cache_miss", "rate")
+
+-- luacheck: ignore 512 431/user 431/store 431/err
 local map_store = {};
 map_store.__index = map_store;
 map_store.remove = {};
@@ -225,13 +233,94 @@
 	return result;
 end
 
+function map_store:get_all(key)
+	if type(key) ~= "string" or key == "" then
+		return nil, "get_all only supports non-empty string keys";
+	end
+	local ok, result = engine:transaction(function()
+		local query = [[
+		SELECT "user", "type", "value"
+		FROM "prosody"
+		WHERE "host"=? AND "store"=? AND "key"=?
+		]];
+
+		local data;
+		for row in engine:select(query, host, self.store, key) do
+			local key_data, err = deserialize(row[2], row[3]);
+			assert(key_data ~= nil, err);
+			if data == nil then
+				data = {};
+			end
+			data[row[1]] = key_data;
+		end
+
+		return data;
+
+	end);
+	if not ok then return nil, result; end
+	return result;
+end
+
+function map_store:delete_all(key)
+	if type(key) ~= "string" or key == "" then
+		return nil, "delete_all only supports non-empty string keys";
+	end
+	local ok, result = engine:transaction(function()
+		local delete_sql = [[
+		DELETE FROM "prosody"
+		WHERE "host"=? AND "store"=? AND "key"=?;
+		]];
+		engine:delete(delete_sql, host, self.store, key);
+		return true;
+	end);
+	if not ok then return nil, result; end
+	return result;
+end
+
 local archive_store = {}
 archive_store.caps = {
 	total = true;
+	quota = archive_item_limit;
+	truncate = true;
+	full_id_range = true;
+	ids = true;
+	wildcard_delete = true;
 };
 archive_store.__index = archive_store
 function archive_store:append(username, key, value, when, with)
 	local user,store = username,self.store;
+	local cache_key = jid_join(username, host, store);
+	local item_count = archive_item_count_cache:get(cache_key);
+	if not item_count then
+		item_count_cache_miss();
+		local ok, ret = engine:transaction(function()
+			local count_sql = [[
+			SELECT COUNT(*) FROM "prosodyarchive"
+			WHERE "host"=? AND "user"=? AND "store"=?;
+			]];
+			local result = engine:select(count_sql, host, user, store);
+			if result then
+				for row in result do
+					item_count = row[1];
+				end
+			end
+		end);
+		if not ok or not item_count then
+			module:log("error", "Failed while checking quota for %s: %s", username, ret);
+			return nil, "Failure while checking quota";
+		end
+		archive_item_count_cache:set(cache_key, item_count);
+	else
+		item_count_cache_hit();
+	end
+
+	if archive_item_limit then
+		module:log("debug", "%s has %d items out of %d limit", username, item_count, archive_item_limit);
+		if item_count >= archive_item_limit then
+			return nil, "quota-limit";
+		end
+	end
+
 	when = when or os.time();
 	with = with or "";
 	local ok, ret = engine:transaction(function()
@@ -245,12 +334,16 @@
 		VALUES (?,?,?,?,?,?,?,?);
 		]];
 		if key then
-			engine:delete(delete_sql, host, user or "", store, key);
+			local result = engine:delete(delete_sql, host, user or "", store, key);
+			if result then
+				item_count = item_count - result:affected();
+			end
 		else
 			key = uuid.generate();
 		end
 		local t, encoded_value = assert(serialize(value));
 		engine:insert(insert_sql, host, user or "", store, when, with, key, t, encoded_value);
+		archive_item_count_cache:set(cache_key, item_count+1);
 		return key;
 	end);
 	if not ok then return ok, ret; end
@@ -285,47 +378,65 @@
 		where[#where+1] = "\"key\" = ?";
 		args[#args+1] = query.key
 	end
+
+	-- Set of ids
+	if query.ids then
+		local nids, nargs = #query.ids, #args;
+		-- COMPAT Lua 5.1: No separator argument to string.rep
+		where[#where + 1] = "\"key\" IN (" .. string.rep("?,", nids):sub(1,-2) .. ")";
+		for i, id in ipairs(query.ids) do
+			args[nargs+i] = id;
+		end
+	end
 end
 local function archive_where_id_range(query, args, where)
-	local args_len = #args
 	-- Before or after specific item, exclusive
+	local id_lookup_sql = [[
+	SELECT "sort_id"
+	FROM "prosodyarchive"
+	WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ?
+	LIMIT 1;
+	]];
 	if query.after then  -- keys better be unique!
-		where[#where+1] = [[
-		"sort_id" > COALESCE(
-			(
-				SELECT "sort_id"
-				FROM "prosodyarchive"
-				WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ?
-				LIMIT 1
-			), 0)
-		]];
-		args[args_len+1], args[args_len+2], args[args_len+3], args[args_len+4] = query.after, args[1], args[2], args[3];
-		args_len = args_len + 4
+		local after_id = nil;
+		for row in engine:select(id_lookup_sql, query.after, args[1], args[2], args[3]) do
+			after_id = row[1];
+		end
+		if not after_id then
+			return nil, "item-not-found";
+		end
+		where[#where+1] = '"sort_id" > ?';
+		args[#args+1] = after_id;
 	end
 	if query.before then
-		where[#where+1] = [[
-		"sort_id" < COALESCE(
-			(
-				SELECT "sort_id"
-				FROM "prosodyarchive"
-				WHERE "key" = ? AND "host" = ? AND "user" = ? AND "store" = ?
-				LIMIT 1
-			),
-			(
-				SELECT MAX("sort_id")+1
-				FROM "prosodyarchive"
-			)
-		)
-		]]
-		args[args_len+1], args[args_len+2], args[args_len+3], args[args_len+4] = query.before, args[1], args[2], args[3];
+		local before_id = nil;
+		for row in engine:select(id_lookup_sql, query.before, args[1], args[2], args[3]) do
+			before_id = row[1];
+		end
+		if not before_id then
+			return nil, "item-not-found";
+		end
+		where[#where+1] = '"sort_id" < ?';
+		args[#args+1] = before_id;
 	end
+	return true;
 end
 
 function archive_store:find(username, query)
 	query = query or {};
 	local user,store = username,self.store;
-	local total;
-	local ok, result = engine:transaction(function()
+	local cache_key = jid_join(username, host, self.store);
+	local total = archive_item_count_cache:get(cache_key);
+	(total and item_count_cache_hit or item_count_cache_miss)();
+	if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil and query.ids == nil then
+		-- the query is for the whole archive, so a cached 'total' should be a
+		-- relatively accurate response if that's all that is requested
+		if total ~= nil and query.limit == 0 then return noop, total; end
+	else
+		-- not usable, so refresh it later if needed
+		total = nil;
+	end
+	local ok, result, err = engine:transaction(function()
 		local sql_query = [[
 		SELECT "key", "type", "value", "when", "with"
 		FROM "prosodyarchive"
@@ -338,7 +449,8 @@
 		archive_where(query, args, where);
 
 		-- Total matching
-		if query.total then
+		if query.total and not total then
+
 			local stats = engine:select("SELECT COUNT(*) FROM \"prosodyarchive\" WHERE "
 				.. t_concat(where, " AND "), unpack(args));
 			if stats then
@@ -346,12 +458,16 @@
 					total = row[1];
 				end
 			end
+			if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil and query.ids == nil then
+				archive_item_count_cache:set(cache_key, total);
+			end
 			if query.limit == 0 then -- Skip the real query
 				return noop, total;
 			end
 		end
 
-		archive_where_id_range(query, args, where);
+		local ok, err = archive_where_id_range(query, args, where);
+		if not ok then return ok, err; end
 
 		if query.limit then
 			args[#args+1] = query.limit;
@@ -361,7 +477,8 @@
 			and "DESC" or "ASC", query.limit and " LIMIT ?" or "");
 		return engine:select(sql_query, unpack(args));
 	end);
-	if not ok then return ok, result end
+	if not ok then return ok, result; end
+	if not result then return nil, err; end
 	return function()
 		local row = result();
 		if row ~= nil then
@@ -372,6 +489,93 @@
 	end, total;
 end
 
+function archive_store:get(username, key)
+	local iter, err = self:find(username, { key = key })
+	if not iter then return iter, err; end
+	for _, stanza, when, with in iter do
+		return stanza, when, with;
+	end
+	return nil, "item-not-found";
+end
+
+function archive_store:set(username, key, new_value, new_when, new_with)
+	local user,store = username,self.store;
+	local ok, result = engine:transaction(function ()
+
+		local update_query = [[
+		UPDATE "prosodyarchive"
+		SET %s
+		WHERE %s
+		]];
+		local args = { host, user or "", store, key };
+		local setf = {};
+		local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", "\"key\" = ?"};
+
+		if new_value then
+			table.insert(setf, '"type" = ?')
+			table.insert(setf, '"value" = ?')
+			local t, value = serialize(new_value);
+			table.insert(args, 1, t);
+			table.insert(args, 2, value);
+		end
+
+		if new_when then
+			table.insert(setf, 1, '"when" = ?')
+			table.insert(args, 1, new_when);
+		end
+
+		if new_with then
+			table.insert(setf, 1, '"with" = ?')
+			table.insert(args, 1, new_with);
+		end
+
+		update_query = update_query:format(t_concat(setf, ", "), t_concat(where, " AND "));
+		return engine:update(update_query, unpack(args));
+	end);
+	if not ok then return ok, result; end
+	return result:affected() == 1;
+end
+
+function archive_store:summary(username, query)
+	query = query or {};
+	local user,store = username,self.store;
+	local ok, result = engine:transaction(function()
+		local sql_query = [[
+		SELECT DISTINCT "with", COUNT(*), MIN("when"), MAX("when")
+		FROM "prosodyarchive"
+		WHERE %s
+		GROUP BY "with";
+		]];
+		local args = { host, user or "", store, };
+		local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", };
+
+		archive_where(query, args, where);
+
+		archive_where_id_range(query, args, where);
+
+		if query.limit then
+			args[#args+1] = query.limit;
+		end
+
+		sql_query = sql_query:format(t_concat(where, " AND "));
+		return engine:select(sql_query, unpack(args));
+	end);
+	if not ok then return ok, result end
+	local counts = {};
+	local earliest, latest = {}, {};
+	for row in result do
+		local with, count = row[1], row[2];
+		counts[with] = count;
+		earliest[with] = row[3];
+		latest[with] = row[4];
+	end
+	return {
+		counts = counts;
+		earliest = earliest;
+		latest = latest;
+	};
+end
+
 function archive_store:delete(username, query)
 	query = query or {};
 	local user,store = username,self.store;
@@ -384,7 +588,8 @@
 			table.remove(where, 2);
 		end
 		archive_where(query, args, where);
-		archive_where_id_range(query, args, where);
+		local ok, err = archive_where_id_range(query, args, where);
+		if not ok then return ok, err; end
 		if query.truncate == nil then
 			sql_query = sql_query:format(t_concat(where, " AND "));
 		else
@@ -423,9 +628,28 @@
 		end
 		return engine:delete(sql_query, unpack(args));
 	end);
+	if username == true then
+		archive_item_count_cache:clear();
+	else
+		local cache_key = jid_join(username, host, self.store);
+		archive_item_count_cache:set(cache_key, nil);
+	end
 	return ok and stmt:affected(), stmt;
 end
 
+function archive_store:users()
+	local ok, result = engine:transaction(function()
+		local select_sql = [[
+		SELECT DISTINCT "user"
+		FROM "prosodyarchive"
+		WHERE "host"=? AND "store"=?;
+		]];
+		return engine:select(select_sql, host, self.store);
+	end);
+	if not ok then error(result); end
+	return iterator(result);
+end
+
 local stores = {
 	keyval = keyval_store;
 	map = map_store;
@@ -607,12 +831,13 @@
 end
 
 function module.load()
-	if prosody.prosodyctl then return; end
+	if prosody.process_type == "prosodyctl" then return; end
 	local engines = module:shared("/*/sql/connections");
 	local params = normalize_params(module:get_option("sql", default_params));
-	engine = engines[sql.db2uri(params)];
+	local db_uri = sql.db2uri(params);
+	engine = engines[db_uri];
 	if not engine then
-		module:log("debug", "Creating new engine");
+		module:log("debug", "Creating new engine %s", db_uri);
 		engine = sql:create_engine(params, function (engine) -- luacheck: ignore 431/engine
 			if module:get_option("sql_manage_tables", true) then
 				-- Automatically create table, ignore failure (table probably already exists)
@@ -640,7 +865,7 @@
 
 function module.command(arg)
 	local config = require "core.configmanager";
-	local prosodyctl = require "util.prosodyctl";
+	local hi = require "util.human.io";
 	local command = table.remove(arg, 1);
 	if command == "upgrade" then
 		-- We need to find every unique dburi in the config
@@ -655,7 +880,7 @@
 		end
 		print("");
 		print("Ensure you have working backups of the above databases before continuing! ");
-		if not prosodyctl.show_yesno("Continue with the database upgrade? [yN]") then
+		if not hi.show_yesno("Continue with the database upgrade? [yN]") then
 			print("Ok, no upgrade. But you do have backups, don't you? ...don't you?? :-)");
 			return;
 		end
--- a/plugins/mod_storage_xep0227.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_storage_xep0227.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,26 +2,40 @@
 local ipairs, pairs = ipairs, pairs;
 local setmetatable = setmetatable;
 local tostring = tostring;
-local next = next;
-local t_remove = table.remove;
+local next, unpack = next, table.unpack or unpack; --luacheck: ignore 113/unpack
 local os_remove = os.remove;
 local io_open = io.open;
+local jid_bare = require "util.jid".bare;
+local jid_prep = require "util.jid".prep;
+local jid_join = require "util.jid".join;
 
+local array = require "util.array";
+local base64 = require "util.encodings".base64;
+local dt = require "util.datetime";
+local hex = require "util.hex";
+local it = require "util.iterators";
 local paths = require"util.paths";
+local set = require "util.set";
 local st = require "util.stanza";
 local parse_xml_real = require "util.xml".parse;
 
-local function getXml(user, host)
-	local jid = user.."@"..host;
+local lfs = require "lfs";
+
+local function default_get_user_xml(self, user, host) --luacheck: ignore 212/self
+	local jid = jid_join(user, host);
 	local path = paths.join(prosody.paths.data, jid..".xml");
-	local f = io_open(path);
-	if not f then return; end
+	local f, err = io_open(path);
+	if not f then
+		module:log("debug", "Unable to load XML file for <%s>: %s", jid, err);
+		return;
+	end
+	module:log("debug", "Loaded %s", path);
 	local s = f:read("*a");
 	f:close();
 	return parse_xml_real(s);
 end
-local function setXml(user, host, xml)
-	local jid = user.."@"..host;
+local function default_set_user_xml(self, user, host, xml) --luacheck: ignore 212/self
+	local jid = jid_join(user, host);
 	local path = paths.join(prosody.paths.data, jid..".xml");
 	local f, err = io_open(path, "w");
 	if not f then return f, err; end
@@ -45,34 +59,45 @@
 			end
 		end
 	end
+	module:log("warn", "Unable to find user element in %s", xml and xml:top_tag() or "nothing");
 end
 local function createOuterXml(user, host)
 	return st.stanza("server-data", {xmlns='urn:xmpp:pie:0'})
 		:tag("host", {jid=host})
 			:tag("user", {name = user});
 end
-local function removeFromArray(array, value)
-	for i,item in ipairs(array) do
-		if item == value then
-			t_remove(array, i);
-			return;
-		end
-	end
+
+local function hex_to_base64(s)
+	return base64.encode(hex.decode(s));
 end
-local function removeStanzaChild(s, child)
-	removeFromArray(s.tags, child);
-	removeFromArray(s, child);
+
+local function base64_to_hex(s)
+	return hex.encode(base64.decode(s));
 end
 
 local handlers = {};
 
--- In order to support mod_auth_internal_hashed
+-- In order to support custom account properties
 local extended = "http://prosody.im/protocol/extended-xep0227\1";
 
+local scram_hash_name = module:get_option_string("password_hash", "SHA-1");
+local scram_properties = set.new({ "server_key", "stored_key", "iteration_count", "salt" });
+
 handlers.accounts = {
 	get = function(self, user)
-		user = getUserElement(getXml(user, self.host));
-		if user and user.attr.password then
+		user = getUserElement(self:_get_user_xml(user, self.host));
+		local scram_credentials = user and user:get_child_with_attr(
+			"scram-credentials", "urn:xmpp:pie:0#scram",
+			"mechanism", "SCRAM-"..scram_hash_name
+		);
+		if scram_credentials then
+			return {
+				iteration_count = tonumber(scram_credentials:get_child_text("iter-count"));
+				server_key = base64_to_hex(scram_credentials:get_child_text("server-key"));
+				stored_key = base64_to_hex(scram_credentials:get_child_text("stored-key"));
+				salt = base64.decode(scram_credentials:get_child_text("salt"));
+			};
+		elseif user and user.attr.password then
 			return { password = user.attr.password };
 		elseif user then
 			local data = {};
@@ -85,26 +110,44 @@
 		end
 	end;
 	set = function(self, user, data)
-		if data then
-			local xml = getXml(user, self.host);
-			if not xml then xml = createOuterXml(user, self.host); end
-			local usere = getUserElement(xml);
-			for k, v in pairs(data) do
-				if k == "password" then
-					usere.attr.password = v;
-				else
-					usere.attr[extended..k] = v;
-				end
-			end
-			return setXml(user, self.host, xml);
-		else
-			return setXml(user, self.host, nil);
+		if not data then
+			return self:_set_user_xml(user, self.host, nil);
 		end
+
+		local xml = self:_get_user_xml(user, self.host);
+		if not xml then xml = createOuterXml(user, self.host); end
+		local usere = getUserElement(xml);
+
+		local account_properties = set.new(it.to_array(it.keys(data)));
+
+		-- Include SCRAM credentials if known
+		if account_properties:contains_set(scram_properties) then
+			local scram_el = st.stanza("scram-credentials", { xmlns = "urn:xmpp:pie:0#scram", mechanism = "SCRAM-"..scram_hash_name })
+				:text_tag("server-key", hex_to_base64(data.server_key))
+				:text_tag("stored-key", hex_to_base64(data.stored_key))
+				:text_tag("iter-count", ("%d"):format(data.iteration_count))
+				:text_tag("salt", base64.encode(data.salt));
+			usere:add_child(scram_el);
+			account_properties:exclude(scram_properties);
+		end
+
+		-- Include the password if present
+		if account_properties:contains("password") then
+			usere.attr.password = data.password;
+			account_properties:remove("password");
+		end
+
+		-- Preserve remaining properties as namespaced attributes
+		for property in account_properties do
+			usere.attr[extended..property] = data[property];
+		end
+
+		return self:_set_user_xml(user, self.host, xml);
 	end;
 };
 handlers.vcard = {
 	get = function(self, user)
-		user = getUserElement(getXml(user, self.host));
+		user = getUserElement(self:_get_user_xml(user, self.host));
 		if user then
 			local vcard = user:get_child("vCard", 'vcard-temp');
 			if vcard then
@@ -113,27 +156,24 @@
 		end
 	end;
 	set = function(self, user, data)
-		local xml = getXml(user, self.host);
+		local xml = self:_get_user_xml(user, self.host);
 		local usere = xml and getUserElement(xml);
 		if usere then
-			local vcard = usere:get_child("vCard", 'vcard-temp');
-			if vcard then
-				removeStanzaChild(usere, vcard);
-			elseif not data then
+			usere:remove_children("vCard", "vcard-temp");
+			if not data or not data.attr then
+				-- No data to set, old one deleted, success
 				return true;
 			end
-			if data then
-				vcard = st.deserialize(data);
-				usere:add_child(vcard);
-			end
-			return setXml(user, self.host, xml);
+			local vcard = st.deserialize(data);
+			usere:add_child(vcard);
+			return self:_set_user_xml(user, self.host, xml);
 		end
 		return true;
 	end;
 };
 handlers.private = {
 	get = function(self, user)
-		user = getUserElement(getXml(user, self.host));
+		user = getUserElement(self:_get_user_xml(user, self.host));
 		if user then
 			local private = user:get_child("query", "jabber:iq:private");
 			if private then
@@ -146,19 +186,18 @@
 		end
 	end;
 	set = function(self, user, data)
-		local xml = getXml(user, self.host);
+		local xml = self:_get_user_xml(user, self.host);
 		local usere = xml and getUserElement(xml);
 		if usere then
-			local private = usere:get_child("query", 'jabber:iq:private');
-			if private then removeStanzaChild(usere, private); end
+			usere:remove_children("query", "jabber:iq:private");
 			if data and next(data) ~= nil then
-				private = st.stanza("query", {xmlns='jabber:iq:private'});
+				local private = st.stanza("query", {xmlns='jabber:iq:private'});
 				for _,tag in pairs(data) do
 					private:add_child(st.deserialize(tag));
 				end
 				usere:add_child(private);
 			end
-			return setXml(user, self.host, xml);
+			return self:_set_user_xml(user, self.host, xml);
 		end
 		return true;
 	end;
@@ -166,7 +205,7 @@
 
 handlers.roster = {
 	get = function(self, user)
-		user = getUserElement(getXml(user, self.host));
+		user = getUserElement(self:_get_user_xml(user, self.host));
 		if user then
 			local roster = user:get_child("query", "jabber:iq:roster");
 			if roster then
@@ -196,11 +235,11 @@
 		end
 	end;
 	set = function(self, user, data)
-		local xml = getXml(user, self.host);
+		local xml = self:_get_user_xml(user, self.host);
 		local usere = xml and getUserElement(xml);
 		if usere then
-			local roster = usere:get_child("query", 'jabber:iq:roster');
-			if roster then removeStanzaChild(usere, roster); end
+			local user_jid = jid_join(usere.name, self.host);
+			usere:remove_children("query", "jabber:iq:roster");
 			usere:maptags(function (tag)
 				if tag.attr.xmlns == "jabber:client" and tag.name == "presence" and tag.attr.type == "subscribe" then
 					return nil;
@@ -208,20 +247,23 @@
 				return tag;
 			end);
 			if data and next(data) ~= nil then
-				roster = st.stanza("query", {xmlns='jabber:iq:roster'});
+				local roster = st.stanza("query", {xmlns='jabber:iq:roster'});
 				usere:add_child(roster);
-				for jid, item in pairs(data) do
-					if jid then
-						roster:tag("item", {
-							jid = jid,
-							subscription = item.subscription,
-							ask = item.ask,
-							name = item.name,
-						});
-						for group in pairs(item.groups) do
-							roster:tag("group"):text(group):up();
+				for contact_jid, item in pairs(data) do
+					if contact_jid ~= false then
+						contact_jid = jid_bare(jid_prep(contact_jid));
+						if contact_jid ~= user_jid then -- Skip self-contacts
+							roster:tag("item", {
+								jid = contact_jid,
+								subscription = item.subscription,
+								ask = item.ask,
+								name = item.name,
+							});
+							for group in pairs(item.groups) do
+								roster:tag("group"):text(group):up();
+							end
+							roster:up(); -- move out from item
 						end
-						roster:up(); -- move out from item
 					else
 						roster.attr.version = item.version;
 						for pending_jid in pairs(item.pending) do
@@ -230,23 +272,491 @@
 					end
 				end
 			end
-			return setXml(user, self.host, xml);
+			return self:_set_user_xml(user, self.host, xml);
 		end
 		return true;
 	end;
 };
 
+-- PEP node configuration/etc. (not items)
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner";
+local lib_pubsub = module:require "pubsub";
+handlers.pep = {
+	get = function (self, user)
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return nil;
+		end
+		local nodes = {
+			--[[
+			[node_name] = {
+				name = node_name;
+				config = {};
+				affiliations = {};
+				subscribers = {};
+			};
+			]]
+		};
+		local owner_el = user_el:get_child("pubsub", xmlns_pubsub_owner);
+		if not owner_el then
+			local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+			if not pubsub_el then
+				return nil;
+			end
+			for node_el in pubsub_el:childtags("items") do
+				nodes[node_el.attr.node] = true; -- relies on COMPAT behavior in mod_pep
+			end
+			return nodes;
+		end
+		for node_el in owner_el:childtags() do
+			local node_name = node_el.attr.node;
+			local node = nodes[node_name];
+			if not node then
+				node = {
+					name = node_name;
+					config = {};
+					affiliations = {};
+					subscribers = {};
+				};
+				nodes[node_name] = node;
+			end
+			if node_el.name == "configure" then
+				local form = node_el:get_child("x", "jabber:x:data");
+				if form then
+					node.config = lib_pubsub.node_config_form:data(form);
+				end
+			elseif node_el.name == "affiliations" then
+				for affiliation_el in node_el:childtags("affiliation") do
+					local aff_jid = jid_prep(affiliation_el.attr.jid);
+					local aff_value = affiliation_el.attr.affiliation;
+					if aff_jid and aff_value then
+						node.affiliations[aff_jid] = aff_value;
+					end
+				end
+			elseif node_el.name == "subscriptions" then
+				for subscription_el in node_el:childtags("subscription") do
+					local sub_jid = jid_prep(subscription_el.attr.jid);
+					local sub_state = subscription_el.attr.subscription;
+					if sub_jid and sub_state == "subscribed" then
+						local options;
+						local subscription_options_el = subscription_el:get_child("options");
+						if subscription_options_el then
+							local options_form = subscription_options_el:get_child("x", "jabber:x:data");
+							if options_form then
+								options = lib_pubsub.subscription_options_form:data(options_form);
+							end
+						end
+						node.subscribers[sub_jid] = options or true;
+					end
+				end
+			else
+				module:log("warn", "Ignoring unknown pubsub element: %s", node_el.name);
+			end
+		end
+		return nodes;
+	end;
+	set = function(self, user, data)
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return true;
+		end
+		-- Remove existing data, if any
+		user_el:remove_children("pubsub", xmlns_pubsub_owner);
+
+		-- Generate new data
+		local owner_el = st.stanza("pubsub", { xmlns = xmlns_pubsub_owner });
+
+		for node_name, node_data in pairs(data) do
+			if node_data == true then
+				node_data = { config = {} };
+			end
+			local configure_el = st.stanza("configure", { node = node_name })
+				:add_child(lib_pubsub.node_config_form:form(node_data.config, "submit"));
+			owner_el:add_child(configure_el);
+			if node_data.affiliations and next(node_data.affiliations) ~= nil then
+				local affiliations_el = st.stanza("affiliations", { node = node_name });
+				for aff_jid, aff_value in pairs(node_data.affiliations) do
+					affiliations_el:tag("affiliation", { jid = aff_jid, affiliation = aff_value }):up();
+				end
+				owner_el:add_child(affiliations_el);
+			end
+			if node_data.subscribers and next(node_data.subscribers) ~= nil then
+				local subscriptions_el = st.stanza("subscriptions", { node = node_name });
+				for sub_jid, sub_data in pairs(node_data.subscribers) do
+					local sub_el = st.stanza("subscription", { jid = sub_jid, subscribed = "subscribed" });
+					if sub_data ~= true then
+						local options_form = lib_pubsub.subscription_options_form:form(sub_data, "submit");
+						sub_el:tag("options"):add_child(options_form):up();
+					end
+					subscriptions_el:add_child(sub_el);
+				end
+				owner_el:add_child(subscriptions_el);
+			end
+		end
+
+		user_el:add_child(owner_el);
+
+		return self:_set_user_xml(user, self.host, xml);
+	end;
+};
+
+-- PEP items
+handlers.pep_ = {
+	_stores = function (self, xml) --luacheck: ignore 212/self
+		local store_names = set.new();
+
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return store_names;
+		end
+
+		-- Locate existing pubsub element, if any
+		local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+		if not pubsub_el then
+			return store_names;
+		end
+
+		-- Find node items element, if any
+		for items_el in pubsub_el:childtags("items") do
+			store_names:add("pep_"..items_el.attr.node);
+		end
+		return store_names;
+	end;
+	find = function (self, user, query)
+		-- query keys: limit, reverse, key (id)
+
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return nil, "no 227 user element found";
+		end
+
+		local node_name = self.datastore:match("^pep_(.+)$");
+
+		-- Locate existing pubsub element, if any
+		local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+		if not pubsub_el then
+			return nil;
+		end
+
+		-- Find node items element, if any
+		local node_items_el;
+		for items_el in pubsub_el:childtags("items") do
+			if items_el.attr.node == node_name then
+				node_items_el = items_el;
+				break;
+			end
+		end
+
+		if not node_items_el then
+			return nil;
+		end
+
+		local user_jid = user.."@"..self.host;
+
+		local results = {};
+		for item_el in node_items_el:childtags("item") do
+			if query and query.key then
+				if item_el.attr.id == query.key then
+					table.insert(results, { item_el.attr.id, item_el.tags[1], 0, user_jid });
+					break;
+				end
+			else
+				table.insert(results, { item_el.attr.id, item_el.tags[1], 0, user_jid });
+			end
+			if query and query.limit and #results >= query.limit then
+				break;
+			end
+		end
+		if query and query.reverse then
+			return array.reverse(results);
+		end
+		local i = 0;
+		return function ()
+			i = i + 1;
+			local v = results[i];
+			if v == nil then return nil; end
+			return unpack(v, 1, 4);
+		end;
+	end;
+	append = function (self, user, key, payload, when, with) --luacheck: ignore 212/when 212/with 212/key
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return true;
+		end
+
+		local node_name = self.datastore:match("^pep_(.+)$");
+
+		-- Locate existing pubsub element, if any
+		local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+		if not pubsub_el then
+			pubsub_el = st.stanza("pubsub", { xmlns = xmlns_pubsub });
+			user_el:add_child(pubsub_el);
+		end
+
+		-- Find node items element, if any
+		local node_items_el;
+		for items_el in pubsub_el:childtags("items") do
+			if items_el.attr.node == node_name then
+				node_items_el = items_el;
+				break;
+			end
+		end
+
+		if not node_items_el then
+			-- Doesn't exist yet, create one
+			node_items_el = st.stanza("items", { node = node_name });
+			pubsub_el:add_child(node_items_el);
+		end
+
+		-- Append item to pubsub_el
+		local item_el = st.stanza("item", { id = key })
+			:add_child(payload);
+		node_items_el:add_child(item_el);
+
+		return self:_set_user_xml(user, self.host, xml);
+	end;
+	delete = function (self, user, query)
+		-- query keys: limit, reverse, key (id)
+
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return nil, "no 227 user element found";
+		end
+
+		local node_name = self.datastore:match("^pep_(.+)$");
+
+		-- Locate existing pubsub element, if any
+		local pubsub_el = user_el:get_child("pubsub", xmlns_pubsub);
+		if not pubsub_el then
+			return nil;
+		end
+
+		-- Find node items element, if any
+		local node_items_el;
+		for items_el in pubsub_el:childtags("items") do
+			if items_el.attr.node == node_name then
+				node_items_el = items_el;
+				break;
+			end
+		end
+
+		if not node_items_el then
+			return nil;
+		end
+
+		local results = array();
+		for item_el in pubsub_el:childtags("item") do
+			if query and query.key then
+				if item_el.attr.id == query.key then
+					table.insert(results, item_el);
+					break;
+				end
+			else
+				table.insert(results, item_el);
+			end
+			if query and query.limit and #results >= query.limit then
+				break;
+			end
+		end
+		if query and query.truncate then
+			results:sub(-query.truncate);
+		end
+
+		-- Actually remove the matching items
+		local delete_keys = set.new(results:map(function (item) return item.attr.id; end));
+		pubsub_el:maptags(function (item_el)
+			if delete_keys:contains(item_el.attr.id) then
+				return nil;
+			end
+			return item_el;
+		end);
+		return self:_set_user_xml(user, self.host, xml);
+	end;
+};
+
+-- MAM archives
+local xmlns_pie_mam = "urn:xmpp:pie:0#mam";
+handlers.archive = {
+	find = function (self, user, query)
+		assert(query == nil, "XEP-0313 queries are not supported on XEP-0227 files");
+
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return nil, "no 227 user element found";
+		end
+
+		-- Locate existing archive element, if any
+		local archive_el = user_el:get_child("archive", xmlns_pie_mam);
+		if not archive_el then
+			return nil;
+		end
+
+		local user_jid = user.."@"..self.host;
+
+
+		local f, s, result_el = archive_el:childtags("result", "urn:xmpp:mam:2");
+		return function ()
+			result_el = f(s, result_el);
+			if not result_el then return nil; end
+
+			local id = result_el.attr.id;
+			local item = result_el:find("{urn:xmpp:forward:0}forwarded/{jabber:client}message");
+			assert(item, "Invalid stanza in XEP-0227 archive");
+			local when = dt.parse(result_el:find("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp"));
+			local to_bare, from_bare = jid_bare(item.attr.to), jid_bare(item.attr.from);
+			local with = to_bare == user_jid and from_bare or to_bare;
+			-- id, item, when, with
+			return id, item, when, with;
+		end;
+	end;
+	append = function (self, user, key, payload, when, with) --luacheck: ignore 212/when 212/with 212/key
+		local xml = self:_get_user_xml(user, self.host);
+		local user_el = xml and getUserElement(xml);
+		if not user_el then
+			return true;
+		end
+
+		-- Locate existing archive element, if any
+		local archive_el = user_el:get_child("archive", xmlns_pie_mam);
+		if not archive_el then
+			archive_el = st.stanza("archive", { xmlns = xmlns_pie_mam });
+			user_el:add_child(archive_el);
+		end
+
+		local item = st.clone(payload);
+		item.attr.xmlns = "jabber:client";
+
+		local result_el = st.stanza("result", { xmlns = "urn:xmpp:mam:2", id = key })
+			:tag("forwarded", { xmlns = "urn:xmpp:forward:0" })
+				:tag("delay", { xmlns = "urn:xmpp:delay", stamp = dt.datetime(when) }):up()
+				:add_child(item)
+			:up();
+
+		-- Append item to archive_el
+		archive_el:add_child(result_el);
+
+		return self:_set_user_xml(user, self.host, xml);
+	end;
+};
 
 -----------------------------
 local driver = {};
 
+local function users(self)
+	local file_patt = "^.*@"..(self.host:gsub("%p", "%%%1")).."%.xml$";
+
+	local f, s, filename = lfs.dir(prosody.paths.data);
+
+	return function ()
+		filename = f(s, filename);
+		while filename and not filename:match(file_patt) do
+			filename = f(s, filename);
+		end
+		if not filename then return nil; end
+		return filename:match("^[^@]+");
+	end;
+end
+
 function driver:open(datastore, typ) -- luacheck: ignore 212/self
-	if typ and typ ~= "keyval" then return nil, "unsupported-store"; end
+	if typ and typ ~= "keyval" and typ ~= "archive" then return nil, "unsupported-store"; end
 	local handler = handlers[datastore];
+	if not handler and datastore:match("^pep_") then
+		handler = handlers.pep_;
+	end
 	if not handler then return nil, "unsupported-datastore"; end
-	local instance = setmetatable({ host = module.host; datastore = datastore; }, { __index = handler });
+	local instance = setmetatable({
+			host = module.host;
+			datastore = datastore;
+			users = users;
+			_get_user_xml = assert(default_get_user_xml);
+			_set_user_xml = default_set_user_xml;
+		}, {
+			__index = handler;
+		}
+	);
 	if instance.init then instance:init(); end
 	return instance;
 end
 
+-- Custom API that allows some configuration
+function driver:open_xep0227(datastore, typ, options)
+	local instance, err = self:open(datastore, typ);
+	if not instance then
+		return instance, err;
+	end
+	if options then
+		instance._set_user_xml = assert(options.set_user_xml);
+		instance._get_user_xml = assert(options.get_user_xml);
+	end
+	return instance;
+end
+
+local function get_store_names_from_xml(self, user_xml)
+	local stores = set.new();
+	for handler_name, handler_funcs in pairs(handlers) do
+		if handler_funcs._stores then
+			stores:include(handler_funcs._stores(self, user_xml));
+		else
+			stores:add(handler_name);
+		end
+	end
+	return stores;
+end
+
+local function get_store_names(self, path)
+	local stores = set.new();
+	local f, err = io_open(paths.join(prosody.paths.data, path));
+	if not f then
+		module:log("warn", "Unable to load XML file for <%s>: %s", "store listing", err);
+		return stores;
+	end
+	module:log("info", "Loaded %s", path);
+	local s = f:read("*a");
+	f:close();
+	local user_xml = parse_xml_real(s);
+	return get_store_names_from_xml(self, user_xml);
+end
+
+function driver:stores(username)
+	local store_dir = prosody.paths.data;
+
+	local mode, err = lfs.attributes(store_dir, "mode");
+	if not mode then
+		return function() module:log("debug", "Could not iterate over stores in %s: %s", store_dir, err); end
+	end
+
+	local file_patt = "^.*@"..(module.host:gsub("%p", "%%%1")).."%.xml$";
+
+	local all_users = username == true;
+
+	local store_names = set.new();
+
+	for filename in lfs.dir(prosody.paths.data) do
+		if filename:match(file_patt) then
+			if all_users or filename == username.."@"..module.host..".xml" then
+				store_names:include(get_store_names(self, filename));
+				if not all_users then break; end
+			end
+		end
+	end
+
+	return store_names:items();
+end
+
+function driver:xep0227_user_stores(username, host)
+	local user_xml = self:_get_user_xml(username, host);
+	if not user_xml then
+		return nil;
+	end
+	local store_names = get_store_names_from_xml(username, host);
+	return store_names:items();
+end
+
 module:provides("storage", driver);
--- a/plugins/mod_tls.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_tls.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,8 +10,8 @@
 local rawgetopt = require"core.configmanager".rawget;
 local st = require "util.stanza";
 
-local c2s_require_encryption = module:get_option("c2s_require_encryption", module:get_option("require_encryption"));
-local s2s_require_encryption = module:get_option("s2s_require_encryption");
+local c2s_require_encryption = module:get_option("c2s_require_encryption", module:get_option("require_encryption", true));
+local s2s_require_encryption = module:get_option("s2s_require_encryption", true);
 local allow_s2s_tls = module:get_option("s2s_allow_encryption") ~= false;
 local s2s_secure_auth = module:get_option("s2s_secure_auth");
 
@@ -35,9 +35,10 @@
 
 local ssl_ctx_c2s, ssl_ctx_s2sout, ssl_ctx_s2sin;
 local ssl_cfg_c2s, ssl_cfg_s2sout, ssl_cfg_s2sin;
+local err_c2s, err_s2sin, err_s2sout;
 
 function module.load(reload)
-	local NULL, err = {};
+	local NULL = {};
 	local modhost = module.host;
 	local parent = modhost:match("%.(.*)$");
 
@@ -53,16 +54,21 @@
 	local host_s2s   = rawgetopt(modhost, "s2s_ssl") or parent_s2s;
 
 	module:log("debug", "Creating context for c2s");
-	ssl_ctx_c2s, err, ssl_cfg_c2s = create_context(host.host, "server", host_c2s, host_ssl, global_c2s); -- for incoming client connections
-	if not ssl_ctx_c2s then module:log("error", "Error creating context for c2s: %s", err); end
+	local request_client_certs = { verify = { "peer", "client_once", }; };
+	local xmpp_alpn = { alpn = "xmpp-server" };
+
+	ssl_ctx_c2s, err_c2s, ssl_cfg_c2s = create_context(host.host, "server", host_c2s, host_ssl, global_c2s); -- for incoming client connections
+	if not ssl_ctx_c2s then module:log("error", "Error creating context for c2s: %s", err_c2s); end
 
 	module:log("debug", "Creating context for s2sout");
-	ssl_ctx_s2sout, err, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s); -- for outgoing server connections
-	if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err); end
+	-- for outgoing server connections
+	ssl_ctx_s2sout, err_s2sout, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s, request_client_certs, xmpp_alpn);
+	if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err_s2sout); end
 
 	module:log("debug", "Creating context for s2sin");
-	ssl_ctx_s2sin, err, ssl_cfg_s2sin = create_context(host.host, "server", host_s2s, host_ssl, global_s2s); -- for incoming server connections
-	if not ssl_ctx_s2sin then module:log("error", "Error creating contexts for s2sin: %s", err); end
+	-- for incoming server connections
+	ssl_ctx_s2sin, err_s2sin, ssl_cfg_s2sin = create_context(host.host, "server", host_s2s, host_ssl, global_s2s, request_client_certs);
+	if not ssl_ctx_s2sin then module:log("error", "Error creating contexts for s2sin: %s", err_s2sin); end
 
 	if reload then
 		module:log("info", "Certificates reloaded");
@@ -74,7 +80,7 @@
 module:hook_global("config-reloaded", module.load);
 
 local function can_do_tls(session)
-	if not session.conn.starttls then
+	if session.conn and not session.conn.starttls then
 		if not session.secure then
 			session.log("debug", "Underlying connection does not support STARTTLS");
 		end
@@ -83,12 +89,21 @@
 		return session.ssl_ctx;
 	end
 	if session.type == "c2s_unauthed" then
+		if not ssl_ctx_c2s and c2s_require_encryption then
+			session.log("error", "No TLS context available for c2s. Earlier error was: %s", err_c2s);
+		end
 		session.ssl_ctx = ssl_ctx_c2s;
 		session.ssl_cfg = ssl_cfg_c2s;
 	elseif session.type == "s2sin_unauthed" and allow_s2s_tls then
+		if not ssl_ctx_s2sin and s2s_require_encryption then
+			session.log("error", "No TLS context available for s2sin. Earlier error was: %s", err_s2sin);
+		end
 		session.ssl_ctx = ssl_ctx_s2sin;
 		session.ssl_cfg = ssl_cfg_s2sin;
 	elseif session.direction == "outgoing" and allow_s2s_tls then
+		if not ssl_ctx_s2sout and s2s_require_encryption then
+			session.log("error", "No TLS context available for s2sout. Earlier error was: %s", err_s2sout);
+		end
 		session.ssl_ctx = ssl_ctx_s2sout;
 		session.ssl_cfg = ssl_cfg_s2sout;
 	else
@@ -102,11 +117,17 @@
 	return session.ssl_ctx;
 end
 
+module:hook("s2sout-created", function (event)
+	-- Initialize TLS context for outgoing connections
+	can_do_tls(event.session);
+end);
+
 -- Hook <starttls/>
 module:hook("stanza/urn:ietf:params:xml:ns:xmpp-tls:starttls", function(event)
 	local origin = event.origin;
 	if can_do_tls(origin) then
 		(origin.sends2s or origin.send)(starttls_proceed);
+		if origin.destroyed then return end
 		origin:reset_stream();
 		origin.conn:starttls(origin.ssl_ctx);
 		origin.log("debug", "TLS negotiation started for %s...", origin.type);
@@ -119,7 +140,7 @@
 	return true;
 end);
 
--- Advertize stream feature
+-- Advertise stream feature
 module:hook("stream-features", function(event)
 	local origin, features = event.origin, event.features;
 	if can_do_tls(origin) then
@@ -136,13 +157,28 @@
 -- For s2sout connections, start TLS if we can
 module:hook_tag("http://etherx.jabber.org/streams", "features", function (session, stanza)
 	module:log("debug", "Received features element");
-	if can_do_tls(session) and stanza:get_child("starttls", xmlns_starttls) then
-		module:log("debug", "%s is offering TLS, taking up the offer...", session.to_host);
+	if can_do_tls(session) then
+		if stanza:get_child("starttls", xmlns_starttls) then
+			module:log("debug", "%s is offering TLS, taking up the offer...", session.to_host);
+		elseif s2s_require_encryption then
+			module:log("debug", "%s is *not* offering TLS, trying anyways!", session.to_host);
+		else
+			module:log("debug", "%s is not offering TLS", session.to_host);
+			return;
+		end
 		session.sends2s(starttls_initiate);
 		return true;
 	end
 end, 500);
 
+module:hook("s2sout-authenticate-legacy", function(event)
+	local session = event.origin;
+	if s2s_require_encryption and can_do_tls(session) then
+		session.sends2s(starttls_initiate);
+		return true;
+	end
+end, 200);
+
 module:hook_tag(xmlns_starttls, "proceed", function (session, stanza) -- luacheck: ignore 212/stanza
 	if session.type == "s2sout_unauthed" and can_do_tls(session) then
 		module:log("debug", "Proceeding with TLS on s2sout...");
@@ -152,3 +188,9 @@
 		return true;
 	end
 end);
+
+module:hook_tag(xmlns_starttls, "failure", function (session, stanza) -- luacheck: ignore 212/stanza
+	module:log("warn", "TLS negotiation with %s failed.", session.to_host);
+	session:close(nil, "TLS negotiation failed");
+	return false;
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_tokenauth.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,82 @@
+local id = require "util.id";
+local jid = require "util.jid";
+local base64 = require "util.encodings".base64;
+
+local token_store = module:open_store("auth_tokens", "map");
+
+function create_jid_token(actor_jid, token_jid, token_scope, token_ttl)
+	token_jid = jid.prep(token_jid);
+	if not actor_jid or token_jid ~= actor_jid and not jid.compare(token_jid, actor_jid) then
+		return nil, "not-authorized";
+	end
+
+	local token_username, token_host, token_resource = jid.split(token_jid);
+
+	if token_host ~= module.host then
+		return nil, "invalid-host";
+	end
+
+	local token_info = {
+		owner = actor_jid;
+		created = os.time();
+		expires = token_ttl and (os.time() + token_ttl) or nil;
+		jid = token_jid;
+		session = {
+			username = token_username;
+			host = token_host;
+			resource = token_resource;
+
+			auth_scope = token_scope;
+		};
+	};
+
+	local token_id = id.long();
+	local token = base64.encode("1;"..jid.join(token_username, token_host)..";"..token_id);
+	token_store:set(token_username, token_id, token_info);
+
+	return token, token_info;
+end
+
+local function parse_token(encoded_token)
+	local token = base64.decode(encoded_token);
+	if not token then return nil; end
+	local token_jid, token_id = token:match("^1;([^;]+);(.+)$");
+	if not token_jid then return nil; end
+	local token_user, token_host = jid.split(token_jid);
+	return token_id, token_user, token_host;
+end
+
+function get_token_info(token)
+	local token_id, token_user, token_host = parse_token(token);
+	if not token_id then
+		return nil, "invalid-token-format";
+	end
+	if token_host ~= module.host then
+		return nil, "invalid-host";
+	end
+
+	local token_info, err = token_store:get(token_user, token_id);
+	if not token_info then
+		if err then
+			return nil, "internal-error";
+		end
+		return nil, "not-authorized";
+	end
+
+	if token_info.expires and token_info.expires < os.time() then
+		return nil, "not-authorized";
+	end
+
+	return token_info
+end
+
+function revoke_token(token)
+	local token_id, token_user, token_host = parse_token(token);
+	if not token_id then
+		return nil, "invalid-token-format";
+	end
+	if token_host ~= module.host then
+		return nil, "invalid-host";
+	end
+	return token_store:set(token_user, token_id, nil);
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_tombstones.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,110 @@
+-- TODO warn when trying to create an user before the tombstone expires
+-- e.g. via telnet or other admin interface
+local datetime = require "util.datetime";
+local errors = require "util.error";
+local jid_node = require"util.jid".node;
+local st = require "util.stanza";
+
+-- Using a map store as key-value store so that removal of all user data
+-- does not also remove the tombstone, which would defeat the point
+local graveyard = module:open_store(nil, "map");
+local graveyard_cache = require "util.cache".new(module:get_option_number("tombstone_cache_size", 1024));
+
+local ttl = module:get_option_number("user_tombstone_expiry", nil);
+-- Keep tombstones forever by default
+--
+-- Rationale:
+-- There is no way to be completely sure when remote services have
+-- forgotten and revoked all memberships.
+
+-- TODO If the user left a JID they moved to, return a gone+redirect error
+-- TODO Attempt to deregister from MUCs based on bookmarks
+-- TODO Unsubscribe from pubsub services if a notification is received
+
+module:hook_global("user-deleted", function(event)
+	if event.host == module.host then
+		local ok, err = graveyard:set(nil, event.username, os.time());
+		if not ok then module:log("error", "Could store tombstone for %s: %s", event.username, err); end
+	end
+end);
+
+-- Public API
+function has_tombstone(username)
+	local tombstone;
+
+	-- Check cache
+	local cached_result = graveyard_cache:get(username);
+	if cached_result == false then
+		-- We cached that there is no tombstone for this user
+		return false;
+	elseif cached_result then
+		tombstone = cached_result;
+	else
+		local stored_result, err = graveyard:get(nil, username);
+		if not stored_result and not err then
+			-- Cache that there is no tombstone for this user
+			graveyard_cache:set(username, false);
+			return false;
+		elseif err then
+			-- Failed to check tombstone status
+			return nil, err;
+		end
+		-- We have a tombstone stored, so let's continue with that
+		tombstone = stored_result;
+	end
+
+	-- Check expiry
+	if ttl and tombstone + ttl < os.time() then
+		module:log("debug", "Tombstone for %s created at %s has expired", username, datetime.datetime(tombstone));
+		graveyard:set(nil, username, nil);
+		graveyard_cache:set(username, nil); -- clear cache entry (if any)
+		return nil;
+	end
+
+	-- Cache for the future
+	graveyard_cache:set(username, tombstone);
+
+	return tombstone;
+end
+
+module:hook("user-registering", function(event)
+	local tombstone, err = has_tombstone(event.username);
+
+	if err then
+		event.allowed, event.error = errors.coerce(false, err);
+		return true;
+	elseif not tombstone then
+		-- Feel free
+		return;
+	end
+
+	module:log("debug", "Tombstone for %s created at %s", event.username, datetime.datetime(tombstone));
+	event.allowed = false;
+	return true;
+end);
+
+module:hook("presence/bare", function(event)
+	local origin, presence = event.origin, event.stanza;
+	local local_username = jid_node(presence.attr.to);
+	if not local_username then return; end
+
+	-- We want to undo any left-over presence subscriptions and notify the former
+	-- contact that they're gone.
+	--
+	-- FIXME This leaks that the user once existed. Hard to avoid without keeping
+	-- the contact list in some form, which we don't want to do for privacy
+	-- reasons.  Bloom filter perhaps?
+
+	local pres_type = presence.attr.type;
+	local is_probe = pres_type == "probe";
+	local is_normal = pres_type == nil or pres_type == "unavailable";
+	if is_probe and has_tombstone(local_username) then
+		origin.send(st.error_reply(presence, "cancel", "gone", "User deleted"));
+		origin.send(st.presence({ type = "unsubscribed"; to = presence.attr.from; from = presence.attr.to }));
+		return true;
+	elseif is_normal and has_tombstone(local_username) then
+		origin.send(st.error_reply(presence, "cancel", "gone", "User deleted"));
+		origin.send(st.presence({ type = "unsubscribe"; to = presence.attr.from; from = presence.attr.to }));
+		return true;
+	end
+end, 1);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_turn_external.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,42 @@
+local set = require "util.set";
+
+local secret = module:get_option_string("turn_external_secret");
+local host = module:get_option_string("turn_external_host", module.host);
+local user = module:get_option_string("turn_external_user");
+local port = module:get_option_number("turn_external_port", 3478);
+local ttl = module:get_option_number("turn_external_ttl", 86400);
+local tcp = module:get_option_boolean("turn_external_tcp", false);
+local tls_port = module:get_option_number("turn_external_tls_port");
+
+if not secret then
+	module:log_status("error", "Failed to initialize: the 'turn_external_secret' option is not set in your configuration");
+	return;
+end
+
+local services = set.new({ "stun-udp"; "turn-udp" });
+if tcp then
+	services:add("stun-tcp");
+	services:add("turn-tcp");
+end
+if tls_port then
+	services:add("turns-tcp");
+end
+
+module:depends "external_services";
+
+for _, type in ipairs({ "stun"; "turn"; "turns" }) do
+	for _, transport in ipairs({"udp"; "tcp"}) do
+		if services:contains(type .. "-" .. transport) then
+			module:add_item("external_service", {
+				type = type;
+				transport = transport;
+				host = host;
+				port = type == "turns" and tls_port or port;
+
+				username = type == "turn" and user or nil;
+				secret = type == "turn" and secret or nil;
+				ttl = type == "turn" and ttl or nil;
+			})
+		end
+	end
+end
--- a/plugins/mod_uptime.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_uptime.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -16,7 +16,7 @@
 
 module:hook("iq-get/host/jabber:iq:last:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))}));
+	origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(("%d"):format(os.difftime(os.time(), start_time)))}));
 	return true;
 end);
 
@@ -42,6 +42,6 @@
 	return { info = uptime_text(), status = "completed" };
 end
 
-local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler);
+local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler, "any");
 
 module:provides("adhoc", descriptor);
--- a/plugins/mod_user_account_management.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_user_account_management.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -53,9 +53,10 @@
 			log("info", "User removed their account: %s@%s", username, host);
 			module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session });
 		else
-			local username = nodeprep(query:get_child_text("username"));
+			local username = query:get_child_text("username");
 			local password = query:get_child_text("password");
 			if username and password then
+				username = nodeprep(username);
 				if username == session.username then
 					if usermanager_set_password(username, password, session.host, session.resource) then
 						session.send(st.reply(stanza));
--- a/plugins/mod_vcard.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_vcard.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -19,7 +19,7 @@
 	if stanza.attr.type == "get" then
 		local vCard;
 		if to then
-			local node, host = jid_split(to);
+			local node = jid_split(to);
 			vCard = st.deserialize(vcards:get(node)); -- load vCard for user or server
 		else
 			vCard = st.deserialize(vcards:get(session.username));-- load user's own vCard
--- a/plugins/mod_vcard_legacy.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_vcard_legacy.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -38,7 +38,7 @@
 module:hook("iq-get/bare/vcard-temp:vCard", function (event)
 	local origin, stanza = event.origin, event.stanza;
 	local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username);
-	local ok, id, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);
+	local ok, _, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);
 
 	local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" });
 	if ok and vcard4_item then
@@ -105,26 +105,46 @@
 					vcard_temp:tag("WORK"):up();
 				end
 				vcard_temp:up();
+			elseif tag.name == "impp" then
+				local uri = tag:get_child_text("uri");
+				if uri and uri:sub(1, 5) == "xmpp:" then
+					vcard_temp:text_tag("JABBERID", uri:sub(6))
+				end
+			elseif tag.name == "org" then
+				vcard_temp:tag("ORG")
+					:text_tag("ORGNAME", tag:get_child_text("text"))
+				:up();
+			end
+		end
+	else
+		local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", stanza.attr.from);
+		if ok and nick_item then
+			local nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick");
+			if nickname then
+				vcard_temp:text_tag("NICKNAME", nickname);
 			end
 		end
 	end
 
-	local meta_ok, avatar_meta = pep_service:get_items("urn:xmpp:avatar:metadata", stanza.attr.from);
-	local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from);
+	local ok, avatar_hash, meta = pep_service:get_last_item("urn:xmpp:avatar:metadata", stanza.attr.from);
+	if ok and avatar_hash then
 
-	if data_ok then
-		for _, hash in ipairs(avatar_data) do
-			local meta = meta_ok and avatar_meta[hash];
-			local data = avatar_data[hash];
-			local info = meta and meta.tags[1]:get_child("info");
+		local info = meta.tags[1]:get_child("info");
+		if info then
 			vcard_temp:tag("PHOTO");
-			if info and info.attr.type then
+
+			if info.attr.type then
 				vcard_temp:text_tag("TYPE", info.attr.type);
 			end
-			if data then
-				vcard_temp:text_tag("BINVAL", data.tags[1]:get_text());
-			elseif info and info.attr.url then
+
+			if info.attr.url then
 				vcard_temp:text_tag("EXTVAL", info.attr.url);
+			elseif info.attr.id then
+				local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from, { info.attr.id });
+				if data_ok and avatar_data and avatar_data[info.attr.id]  then
+					local data = avatar_data[info.attr.id];
+					vcard_temp:text_tag("BINVAL", data.tags[1]:get_text());
+				end
 			end
 			vcard_temp:up();
 		end
@@ -140,7 +160,7 @@
 };
 
 function vcard_to_pep(vcard_temp)
-	local avatars = {};
+	local avatar = {};
 
 	local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" })
 		:tag("vcard", { xmlns = 'urn:ietf:params:xml:ns:vcard-4.0' });
@@ -216,6 +236,10 @@
 				vcard4:text_tag("text", "work");
 			end
 			vcard4:up():up():up();
+		elseif tag.name == "JABBERID" then
+			vcard4:tag("impp")
+				:text_tag("uri", "xmpp:" .. tag:get_text())
+			:up();
 		elseif tag.name == "PHOTO" then
 			local avatar_type = tag:get_child_text("TYPE");
 			local avatar_payload = tag:get_child_text("BINVAL");
@@ -225,7 +249,9 @@
 				local avatar_raw = base64_decode(avatar_payload);
 				local avatar_hash = sha1(avatar_raw, true);
 
-				local avatar_meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
+				avatar.hash = avatar_hash;
+
+				avatar.meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
 					:tag("metadata", { xmlns="urn:xmpp:avatar:metadata" })
 						:tag("info", {
 							bytes = tostring(#avatar_raw),
@@ -233,36 +259,27 @@
 							type = avatar_type,
 						});
 
-				local avatar_data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
+				avatar.data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
 					:tag("data", { xmlns="urn:xmpp:avatar:data" })
 						:text(avatar_payload);
 
-				table.insert(avatars, { hash = avatar_hash, meta = avatar_meta, data = avatar_data });
 			end
 		end
 	end
-	return vcard4, avatars;
+	return vcard4, avatar;
 end
 
-function save_to_pep(pep_service, actor, vcard4, avatars)
-	if avatars then
+function save_to_pep(pep_service, actor, vcard4, avatar)
+	if avatar then
 
 		if pep_service:purge("urn:xmpp:avatar:metadata", actor) then
 			pep_service:purge("urn:xmpp:avatar:data", actor);
 		end
 
-		local avatar_defaults = node_defaults;
-		if #avatars > 1 then
-			avatar_defaults = {};
-			for k,v in pairs(node_defaults) do
-				avatar_defaults[k] = v;
-			end
-			avatar_defaults.max_items = #avatars;
-		end
-		for _, avatar in ipairs(avatars) do
-			local ok, err = pep_service:publish("urn:xmpp:avatar:data", actor, avatar.hash, avatar.data, avatar_defaults);
+		if avatar.data and avatar.meta then
+			local ok, err = assert(pep_service:publish("urn:xmpp:avatar:data", actor, avatar.hash, avatar.data, node_defaults));
 			if ok then
-				ok, err = pep_service:publish("urn:xmpp:avatar:metadata", actor, avatar.hash, avatar.meta, avatar_defaults);
+				ok, err = assert(pep_service:publish("urn:xmpp:avatar:metadata", actor, avatar.hash, avatar.meta, node_defaults));
 			end
 			if not ok then
 				return ok, err;
--- a/plugins/mod_websocket.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/mod_websocket.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -33,18 +33,10 @@
 local frame_fragment_limit = module:get_option_number("websocket_frame_fragment_limit", 8);
 local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5);
 local consider_websocket_secure = module:get_option_boolean("consider_websocket_secure");
-local cross_domain = module:get_option_set("cross_domain_websocket", {});
-if cross_domain:contains("*") or cross_domain:contains(true) then
-	cross_domain = true;
+local cross_domain = module:get_option("cross_domain_websocket");
+if cross_domain ~= nil then
+	module:log("info", "The 'cross_domain_websocket' option has been deprecated");
 end
-
-local function check_origin(origin)
-	if cross_domain == true then
-		return true;
-	end
-	return cross_domain:contains(origin);
-end
-
 local xmlns_framing = "urn:ietf:params:xml:ns:xmpp-framing";
 local xmlns_streams = "http://etherx.jabber.org/streams";
 local xmlns_client = "jabber:client";
@@ -79,6 +71,8 @@
 			local stream_error = st.stanza("stream:error");
 			if type(reason) == "string" then -- assume stream error
 				stream_error:tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' });
+			elseif st.is_stanza(reason) then
+				stream_error = reason;
 			elseif type(reason) == "table" then
 				if reason.condition then
 					stream_error:tag(reason.condition, stream_xmlns_attr):up();
@@ -88,11 +82,9 @@
 					if reason.extra then
 						stream_error:add_child(reason.extra);
 					end
-				elseif reason.name then -- a stanza
-					stream_error = reason;
 				end
 			end
-			log("debug", "Disconnecting client, <stream:error> is: %s", tostring(stream_error));
+			log("debug", "Disconnecting client, <stream:error> is: %s", stream_error);
 			session.send(stream_error);
 		end
 
@@ -143,6 +135,14 @@
 	return data;
 end
 
+local default_get_response_text = "It works! Now point your WebSocket client to this URL to connect to Prosody."
+local websocket_get_response_text = module:get_option_string("websocket_get_response_text", default_get_response_text)
+
+local default_get_response_body = [[<!DOCTYPE html><html><head><title>Websocket</title></head><body>
+<p>]]..websocket_get_response_text..[[</p>
+</body></html>]]
+local websocket_get_response_body = module:get_option_string("websocket_get_response_body", default_get_response_body)
+
 local function validate_frame(frame, max_length)
 	local opcode, length = frame.opcode, frame.length;
 
@@ -207,12 +207,15 @@
 
 	conn.starttls = false; -- Prevent mod_tls from believing starttls can be done
 
-	if not request.headers.sec_websocket_key then
-		response.headers.content_type = "text/html";
-		return [[<!DOCTYPE html><html><head><title>Websocket</title></head><body>
-			<p>It works! Now point your WebSocket client to this URL to connect to Prosody.</p>
-			</body></html>]];
-	end
+	if not request.headers.sec_websocket_key or request.method ~= "GET" then
+		return module:fire_event("http-message", {
+			response = event.response;
+			---
+			title = "Prosody WebSocket endpoint";
+			message = websocket_get_response_text;
+			warning = not (consider_websocket_secure or request.secure) and "This endpoint is not considered secure!" or nil;
+		}) or websocket_get_response_body;
+		end
 
 	local wants_xmpp = contains_token(request.headers.sec_websocket_protocol or "", "xmpp");
 
@@ -221,11 +224,6 @@
 		return 501;
 	end
 
-	if not check_origin(request.headers.origin or "") then
-		module:log("debug", "Origin %s is not allowed by 'cross_domain_websocket' [ %s ]", request.headers.origin or "(missing header)", cross_domain);
-		return 403;
-	end
-
 	local function websocket_close(code, message)
 		conn:write(build_close(code, message));
 		conn:close();
@@ -276,7 +274,7 @@
 	-- See mod_http and #540
 	session.ip = request.ip;
 
-	session.secure = consider_websocket_secure or session.secure;
+	session.secure = consider_websocket_secure or request.secure or session.secure;
 	session.websocket_request = request;
 
 	session.open_stream = session_open_stream;
@@ -350,41 +348,25 @@
 	end
 end
 
-module:hook("c2s-read-timeout", keepalive, -0.9);
-
-module:depends("http");
-module:provides("http", {
-	name = "websocket";
-	default_path = "xmpp-websocket";
-	route = {
-		["GET"] = handle_request;
-		["GET /"] = handle_request;
-	};
-});
-
 function module.add_host(module)
 	module:hook("c2s-read-timeout", keepalive, -0.9);
 
-	if cross_domain ~= true then
-		local url = require "socket.url";
-		local ws_url = module:http_url("websocket", "xmpp-websocket");
-		local url_components = url.parse(ws_url);
-		-- The 'Origin' consists of the base URL without path
-		url_components.path = nil;
-		local this_origin = url.build(url_components);
-		local local_cross_domain = module:get_option_set("cross_domain_websocket", { this_origin });
-		if local_cross_domain:contains(true) then
-			module:log("error", "cross_domain_websocket = true only works in the global section");
-			return;
-		end
+	module:depends("http");
+	module:provides("http", {
+		name = "websocket";
+		default_path = "xmpp-websocket";
+		cors = {
+			enabled = true;
+		};
+		route = {
+			["GET"] = handle_request;
+			["GET /"] = handle_request;
+		};
+	});
 
-		-- Don't add / remove something added by another host
-		-- This might be weird with random load order
-		local_cross_domain:exclude(cross_domain);
-		cross_domain:include(local_cross_domain);
-		module:log("debug", "cross_domain = %s", tostring(cross_domain));
-		function module.unload()
-			cross_domain:exclude(local_cross_domain);
-		end
-	end
+	module:hook("c2s-read-timeout", keepalive, -0.9);
 end
+
+if require"core.modulemanager".get_modules_for_host("*"):contains(module.name) then
+	module:add_host();
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/hats.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,26 @@
+local st = require "util.stanza";
+local muc_util = module:require "muc/util";
+
+local xmlns_hats = "xmpp:prosody.im/protocol/hats:1";
+
+-- Strip any hats claimed by the client (to prevent spoofing)
+muc_util.add_filtered_namespace(xmlns_hats);
+
+
+module:hook("muc-build-occupant-presence", function (event)
+	local bare_jid = event.occupant and event.occupant.bare_jid or event.bare_jid;
+	local aff_data = event.room:get_affiliation_data(bare_jid);
+	local hats = aff_data and aff_data.hats;
+	if not hats then return; end
+	local hats_el;
+	for hat_id, hat_data in pairs(hats) do
+		if hat_data.active then
+			if not hats_el then
+				hats_el = st.stanza("hats", { xmlns = xmlns_hats });
+			end
+			hats_el:tag("hat", { uri = hat_id, title = hat_data.title }):up();
+		end
+	end
+	if not hats_el then return; end
+	event.stanza:add_direct_child(hats_el);
+end);
--- a/plugins/muc/history.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/history.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -48,16 +48,18 @@
 	table.insert(event.form, {
 		name = "muc#roomconfig_historylength";
 		type = "text-single";
+		datatype = "xs:integer";
 		label = "Maximum number of history messages returned by room";
 		desc = "Specify the maximum number of previous messages that should be sent to users when they join the room";
-		value = tostring(get_historylength(event.room));
+		value = get_historylength(event.room);
 	});
 	table.insert(event.form, {
 		name = 'muc#roomconfig_defaulthistorymessages',
 		type = 'text-single',
+		datatype = "xs:integer";
 		label = 'Default number of history messages returned by room',
 		desc = "Specify the number of previous messages sent to new users when they join the room";
-		value = tostring(get_defaulthistorymessages(event.room))
+		value = get_defaulthistorymessages(event.room);
 	});
 end, 70-5);
 
@@ -171,6 +173,10 @@
 -- add to history
 module:hook("muc-add-history", function(event)
 	local room = event.room
+	if get_historylength(room) == 0 then
+		room._history = nil;
+		return;
+	end
 	local history = room._history;
 	if not history then history = {}; room._history = history; end
 	local stanza = st.clone(event.stanza);
@@ -180,9 +186,6 @@
 	stanza:tag("delay", { -- XEP-0203
 		xmlns = "urn:xmpp:delay", from = room.jid, stamp = stamp
 	}):up();
-	stanza:tag("x", { -- XEP-0091 (deprecated)
-		xmlns = "jabber:x:delay", from = room.jid, stamp = datetime.legacy()
-	}):up();
 	local entry = { stanza = stanza, timestamp = ts };
 	table.insert(history, entry);
 	while #history > get_historylength(room) do table.remove(history, 1) end
@@ -198,7 +201,27 @@
 end);
 
 module:hook("muc-message-is-historic", function (event)
-	return event.stanza:get_child("body");
+	local stanza = event.stanza;
+	if stanza:get_child("no-store", "urn:xmpp:hints")
+	or stanza:get_child("no-permanent-store", "urn:xmpp:hints") then
+		-- XXX Experimental XEP
+		return false, "hint";
+	end
+	if stanza:get_child("store", "urn:xmpp:hints") then
+		return true, "hint";
+	end
+	if stanza:get_child("body") then
+		return true;
+	end
+	if stanza:get_child("encryption", "urn:xmpp:eme:0") then
+		-- Since we can't know what an encrypted message contains, we assume it's important
+		-- XXX Experimental XEP
+		return true, "encrypted";
+	end
+	if stanza:get_child(nil, "urn:xmpp:chat-markers:0") then
+		-- XXX Experimental XEP
+		return true, "marker";
+	end
 end, -1);
 
 return {
--- a/plugins/muc/language.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/language.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -32,6 +32,7 @@
 		label = "Language tag for room (e.g. 'en', 'de', 'fr' etc.)";
 		type = "text-single";
 		desc = "Indicate the primary language spoken in this room";
+		datatype = "xs:language";
 		value = get_language(event.room) or "";
 	});
 end
--- a/plugins/muc/lock.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/lock.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -43,7 +43,7 @@
 module:hook("muc-occupant-pre-join", function(event)
 	if not event.is_new_room and is_locked(event.room) then -- Deny entry
 		module:log("debug", "Room is locked, denying entry");
-		event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found"));
+		event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found", nil, module.host));
 		return true;
 	end
 end, -30);
--- a/plugins/muc/members_only.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/members_only.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -121,9 +121,8 @@
 		local stanza = event.stanza;
 		local affiliation = room:get_affiliation(stanza.attr.from);
 		if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then
-			local reply = st.error_reply(stanza, "auth", "registration-required"):up();
-			reply.tags[1].attr.code = "407";
-			event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+			local reply = st.error_reply(stanza, "auth", "registration-required", nil, room.jid):up();
+			event.origin.send(reply);
 			return true;
 		end
 	end
@@ -139,7 +138,7 @@
 		local inviter_affiliation = room:get_affiliation(stanza.attr.from) or "none";
 		local required_affiliation = room._data.allow_member_invites and "member" or "admin";
 		if valid_affiliations[inviter_affiliation] < valid_affiliations[required_affiliation] then
-			event.origin.send(st.error_reply(stanza, "auth", "forbidden"));
+			event.origin.send(st.error_reply(stanza, "auth", "forbidden", nil, room.jid));
 			return true;
 		end
 	end
--- a/plugins/muc/mod_muc.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/mod_muc.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -86,7 +86,17 @@
 room_mt.get_registered_jid = register.get_registered_jid;
 room_mt.handle_register_iq = register.handle_register_iq;
 
+local presence_broadcast = module:require "muc/presence_broadcast";
+room_mt.get_presence_broadcast = presence_broadcast.get;
+room_mt.set_presence_broadcast = presence_broadcast.set;
+room_mt.get_valid_broadcast_roles = presence_broadcast.get_valid_broadcast_roles;
+
+local occupant_id = module:require "muc/occupant_id";
+room_mt.get_salt = occupant_id.get_room_salt;
+room_mt.get_occupant_id = occupant_id.get_occupant_id;
+
 local jid_split = require "util.jid".split;
+local jid_prep = require "util.jid".prep;
 local jid_bare = require "util.jid".bare;
 local st = require "util.stanza";
 local cache = require "util.cache";
@@ -98,6 +108,7 @@
 module:add_identity("conference", "text", module:get_option_string("name", "Prosody Chatrooms"));
 module:add_feature("http://jabber.org/protocol/muc");
 module:depends "muc_unique"
+module:require "muc/hats";
 module:require "muc/lock";
 
 local function is_admin(jid)
@@ -129,7 +140,12 @@
 local function room_save(room, forced, savestate)
 	local node = jid_split(room.jid);
 	local is_persistent = persistent.get(room);
-	room_items_cache[room.jid] = room:get_public() and room:get_name() or nil;
+	if room:get_public() then
+		room_items_cache[room.jid] = room:get_name() or "";
+	else
+		room_items_cache[room.jid] = nil;
+	end
+
 	if is_persistent or savestate then
 		persistent_rooms:set(nil, room.jid, true);
 		local data, state = room:freeze(savestate);
@@ -155,7 +171,11 @@
 	end
 	module:log("debug", "Evicting room %s", jid);
 	room_eviction();
-	room_items_cache[room.jid] = room:get_public() and room:get_name() or nil;
+	if room:get_public() then
+		room_items_cache[room.jid] = room:get_name() or "";
+	else
+		room_items_cache[room.jid] = nil;
+	end
 	local ok, err = room_save(room, nil, true); -- Force to disk
 	if not ok then
 		module:log("error", "Failed to swap inactive room %s to disk: %s", jid, err);
@@ -163,6 +183,11 @@
 	end
 end);
 
+local measure_rooms_size = module:measure("live_room", "amount");
+module:hook_global("stats-update", function ()
+	measure_rooms_size(rooms:count());
+end);
+
 -- Automatically destroy empty non-persistent rooms
 module:hook("muc-occupant-left",function(event)
 	local room = event.room
@@ -185,7 +210,7 @@
 
 local function handle_broken_room(room, origin, stanza)
 	module:log("debug", "Returning error from broken room %s", room.jid);
-	origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
+	origin.send(st.error_reply(stanza, "wait", "internal-server-error", nil, room.jid));
 	return true;
 end
 
@@ -264,9 +289,13 @@
 	room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject()));
 	room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength()));
 	room:set_language(lang or module:get_option_string("muc_room_default_language"));
+	room:set_presence_broadcast(module:get_option("muc_room_default_presence_broadcast", room:get_presence_broadcast()));
 end
 
 function create_room(room_jid, config)
+	if jid_bare(room_jid) ~= room_jid or not jid_prep(room_jid, true) then
+		return nil, "invalid-jid";
+	end
 	local exists = get_room_from_jid(room_jid);
 	if exists then
 		return nil, "room-exists";
@@ -325,13 +354,14 @@
 	module:log("debug", "host-disco-items called");
 	if next(room_items_cache) ~= nil then
 		for jid, room_name in pairs(room_items_cache) do
+			if room_name == "" then room_name = nil; end
 			reply:tag("item", { jid = jid, name = room_name }):up();
 		end
 	else
 		for room in all_rooms() do
 			if not room:get_hidden() then
 				local jid, room_name = room.jid, room:get_name();
-				room_items_cache[jid] = room_name;
+				room_items_cache[jid] = room_name or "";
 				reply:tag("item", { jid = jid, name = room_name }):up();
 			end
 		end
@@ -345,7 +375,7 @@
 module:hook("muc-room-pre-create", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	if not track_room(event.room) then
-		origin.send(st.error_reply(stanza, "wait", "resource-constraint"));
+		origin.send(st.error_reply(stanza, "wait", "resource-constraint", nil, module.host));
 		return true;
 	end
 end, -1000);
@@ -396,7 +426,7 @@
 				restrict_room_creation == "local" and
 				select(2, jid_split(user_jid)) == host_suffix
 			) then
-				origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted"));
+				origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted", module.host));
 				return true;
 			end
 		end);
@@ -441,7 +471,7 @@
 				room = nil;
 			else
 				if stanza.attr.type ~= "error" then
-					local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason)
+					local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason, module.host)
 					if room._data.newjid then
 						local uri = "xmpp:"..room._data.newjid.."?join";
 						reply:get_child("error"):child_with_name("gone"):text(uri);
@@ -454,17 +484,21 @@
 
 		if room == nil then
 			-- Watch presence to create rooms
-			if stanza.attr.type == nil and stanza.name == "presence" then
+			if not jid_prep(room_jid, true) then
+				origin.send(st.error_reply(stanza, "modify", "jid-malformed", nil, module.host));
+				return true;
+			end
+			if stanza.attr.type == nil and stanza.name == "presence" and stanza:get_child("x", "http://jabber.org/protocol/muc") then
 				room = muclib.new_room(room_jid);
 				return room:handle_first_presence(origin, stanza);
 			elseif stanza.attr.type ~= "error" then
-				origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+				origin.send(st.error_reply(stanza, "cancel", "item-not-found", nil, module.host));
 				return true;
 			else
 				return;
 			end
 		elseif room == false then -- Error loading room
-			origin.send(st.error_reply(stanza, "wait", "resource-constraint"));
+			origin.send(st.error_reply(stanza, "wait", "resource-constraint", nil, module.host));
 			return true;
 		end
 		return room[method](room, origin, stanza);
@@ -483,6 +517,7 @@
 	local t_concat = table.concat;
 	local adhoc_new = module:require "adhoc".new;
 	local adhoc_initial = require "util.adhoc".new_initial_data_form;
+	local adhoc_simple = require "util.adhoc".new_simple_form;
 	local array = require "util.array";
 	local dataforms_new = require "util.dataforms".new;
 
@@ -504,13 +539,59 @@
 			end
 			return { status = "completed", error = { message = t_concat(errmsg, "\n") } };
 		end
-		for _, room in ipairs(fields.rooms) do
-			get_room_from_jid(room):destroy();
+		local destroyed = array();
+		for _, room_jid in ipairs(fields.rooms) do
+			local room = get_room_from_jid(room_jid);
+			if room and room:destroy() then
+				destroyed:push(room.jid);
+			end
 		end
-		return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") };
+		return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(destroyed, "\n") };
 	end);
 	local destroy_rooms_desc = adhoc_new("Destroy Rooms",
 		"http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin");
 
 	module:provides("adhoc", destroy_rooms_desc);
+
+
+	local set_affiliation_layout = dataforms_new {
+		-- FIXME wordsmith title, instructions, labels etc
+		title = "Set affiliation";
+
+		{ name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#set-affiliation" };
+		{ name = "room", type = "jid-single", required = true, label = "Room"};
+		{ name = "jid", type = "jid-single", required = true, label = "JID"};
+		{ name = "affiliation", type = "list-single", required = true, label = "Affiliation",
+			options = { "owner"; "admin"; "member"; "none"; "outcast"; },
+		};
+		{ name = "reason", type = "text-single", "Reason", }
+	};
+
+	local set_affiliation_handler = adhoc_simple(set_affiliation_layout, function (fields, errors)
+		if errors then
+			local errmsg = {};
+			for field, err in pairs(errors) do
+				errmsg[#errmsg + 1] = field .. ": " .. err;
+			end
+			return { status = "completed", error = { message = t_concat(errmsg, "\n") } };
+		end
+
+		local room = get_room_from_jid(fields.room);
+		if not room then
+			return { status = "canceled", error = { message =  "No such room"; }; };
+		end
+		local ok, err, condition = room:set_affiliation(true, fields.jid, fields.affiliation, fields.reason);
+
+		if not ok then
+			return { status = "canceled", error = { message =  "Affiliation change failed: "..err..":"..condition; }; };
+		end
+
+		return { status = "completed", info = "Affiliation updated",
+		};
+	end);
+
+	local set_affiliation_desc = adhoc_new("Set affiliation in room",
+		"http://prosody.im/protocol/muc#set-affiliation", set_affiliation_handler, "admin");
+
+	module:provides("adhoc", set_affiliation_desc);
 end
--- a/plugins/muc/muc.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/muc.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -22,7 +22,8 @@
 local resourceprep = require "util.encodings".stringprep.resourceprep;
 local st = require "util.stanza";
 local base64 = require "util.encodings".base64;
-local md5 = require "util.hashes".md5;
+local hmac_sha256 = require "util.hashes".hmac_sha256;
+local new_id = require "util.id".medium;
 
 local log = module._log;
 
@@ -39,7 +40,7 @@
 end
 
 function room_mt.save()
-	-- overriden by mod_muc.lua
+	-- overridden by mod_muc.lua
 end
 
 function room_mt:get_occupant_jid(real_jid)
@@ -215,15 +216,16 @@
 	end
 end
 
+
 -- Broadcasts an occupant's presence to the whole room
 -- Takes the x element that goes into the stanzas
-function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason)
+function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason, prev_role, force_unavailable, recipient)
 	local base_x = x.base or x;
 	-- Build real jid and (optionally) occupant jid template presences
 	local base_presence do
 		-- Try to use main jid's presence
 		local pr = occupant:get_presence();
-		if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") then
+		if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") and not force_unavailable then
 			base_presence = st.clone(pr);
 		else -- user is leaving but didn't send a leave presence. make one for them
 			base_presence = st.presence {from = occupant.nick; type = "unavailable";};
@@ -236,7 +238,10 @@
 		occupant = occupant; nick = nick; actor = actor;
 		reason = reason;
 	}
-	module:fire_event("muc-broadcast-presence", event);
+	module:fire_event("muc-build-occupant-presence", event);
+	if not recipient then
+		module:fire_event("muc-broadcast-presence", event);
+	end
 
 	-- Allow muc-broadcast-presence listeners to change things
 	nick = event.nick;
@@ -279,18 +284,34 @@
 		self_p = st.clone(base_presence):add_child(self_x);
 	end
 
-	-- General populance
+	local function get_p(rec_occupant)
+		local pr;
+		if can_see_real_jids(whois, rec_occupant) then
+			pr = get_full_p();
+		elseif occupant.bare_jid == rec_occupant.bare_jid then
+			pr = self_p;
+		else
+			pr = get_anon_p();
+		end
+		return pr
+	end
+
+	if recipient then
+		return self:route_to_occupant(recipient, get_p(recipient));
+	end
+
+	local broadcast_roles = self:get_presence_broadcast();
+	-- General populace
 	for occupant_nick, n_occupant in self:each_occupant() do
 		if occupant_nick ~= occupant.nick then
-			local pr;
-			if can_see_real_jids(whois, n_occupant) then
-				pr = get_full_p();
-			elseif occupant.bare_jid == n_occupant.bare_jid then
-				pr = self_p;
-			else
-				pr = get_anon_p();
+			local pr = get_p(n_occupant);
+			if broadcast_roles[occupant.role or "none"] or force_unavailable then
+				self:route_to_occupant(n_occupant, pr);
+			elseif prev_role and broadcast_roles[prev_role] then
+				pr.attr.type = 'unavailable';
+				self:route_to_occupant(n_occupant, pr);
 			end
-			self:route_to_occupant(n_occupant, pr);
+
 		end
 	end
 
@@ -303,6 +324,7 @@
 		-- use their own presences as templates
 		for full_jid, pr in occupant:each_session() do
 			pr = st.clone(pr);
+			module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pr });
 			pr.attr.to = full_jid;
 			pr:add_child(self_x);
 			self:route_stanza(pr);
@@ -312,25 +334,40 @@
 
 function room_mt:send_occupant_list(to, filter)
 	local to_bare = jid_bare(to);
-	local is_anonymous = false;
-	local whois = self:get_whois();
-	if whois ~= "anyone" then
-		local affiliation = self:get_affiliation(to);
-		if affiliation ~= "admin" and affiliation ~= "owner" then
-			local occupant = self:get_occupant_by_real_jid(to);
-			if not (occupant and can_see_real_jids(whois, occupant)) then
-				is_anonymous = true;
-			end
-		end
-	end
+	local broadcast_roles = self:get_presence_broadcast();
+	local is_anonymous = self:is_anonymous_for(to);
+	local broadcast_bare_jids = {}; -- Track which bare JIDs we have sent presence for
 	for occupant_jid, occupant in self:each_occupant() do
+		broadcast_bare_jids[occupant.bare_jid] = true;
 		if filter == nil or filter(occupant_jid, occupant) then
 			local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
 			self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids
 			local pres = st.clone(occupant:get_presence());
 			pres.attr.to = to;
 			pres:add_child(x);
-			self:route_stanza(pres);
+			module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pres });
+			if to_bare == occupant.bare_jid or broadcast_roles[occupant.role or "none"] then
+				self:route_stanza(pres);
+			end
+		end
+	end
+	if broadcast_roles.none then
+		-- Broadcast stanzas for affiliated users not currently in the MUC
+		for affiliated_jid, affiliation, affiliation_data in self:each_affiliation() do
+			local nick = affiliation_data and affiliation_data.reserved_nickname;
+			if (nick or not is_anonymous) and not broadcast_bare_jids[affiliated_jid]
+			and (filter == nil or filter(affiliated_jid, nil)) then
+				local from = nick and (self.jid.."/"..nick) or self.jid;
+				local pres = st.presence({ to = to, from = from, type = "unavailable" })
+					:tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' })
+						:tag("item", {
+							affiliation = affiliation;
+							role = "none";
+							nick = nick;
+							jid = not is_anonymous and affiliated_jid or nil }):up()
+						:up();
+				self:route_stanza(pres);
+			end
 		end
 	end
 end
@@ -373,13 +410,14 @@
 	local real_jid = stanza.attr.from;
 	local occupant = self:get_occupant_by_real_jid(real_jid);
 	if occupant == nil then return nil; end
-	local type, condition, text = stanza:get_error();
+	local _, condition, text = stanza:get_error();
 	local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error");
 	if text and self:get_whois() == "anyone" then
 		error_message = error_message..": "..text;
 	end
 	occupant:set_session(real_jid, st.presence({type="unavailable"})
 		:tag('status'):text(error_message));
+	local orig_role = occupant.role;
 	local is_last_session = occupant.jid == real_jid;
 	if is_last_session then
 		occupant.role = nil;
@@ -389,9 +427,13 @@
 	if is_last_session then
 		x:tag("status", {code = "333"});
 	end
-	self:publicise_occupant_status(new_occupant or occupant, x);
+	self:publicise_occupant_status(new_occupant or occupant, x, nil, nil, nil, orig_role);
 	if is_last_session then
-		module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+		module:fire_event("muc-occupant-left", {
+				room = self;
+				nick = occupant.nick;
+				occupant = occupant;
+			});
 	end
 	return true;
 end
@@ -406,36 +448,48 @@
 	local room, stanza = event.room, event.stanza;
 	local affiliation = room:get_affiliation(stanza.attr.from);
 	if affiliation == "outcast" then
-		local reply = st.error_reply(stanza, "auth", "forbidden"):up();
-		reply.tags[1].attr.code = "403";
-		event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		local reply = st.error_reply(stanza, "auth", "forbidden", nil, room.jid):up();
+		event.origin.send(reply);
 		return true;
 	end
 end, -10);
 
 module:hook("muc-occupant-pre-join", function(event)
+	local room = event.room;
 	local nick = jid_resource(event.occupant.nick);
 	if not nick:find("%S") then
-		event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden"));
+		event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden", room.jid));
 		return true;
 	end
 end, 1);
 
 module:hook("muc-occupant-pre-change", function(event)
+	local room = event.room;
 	if not jid_resource(event.dest_occupant.nick):find("%S") then
-		event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden"));
+		event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden", room.jid));
 		return true;
 	end
 end, 1);
 
-function room_mt:handle_first_presence(origin, stanza)
-	if not stanza:get_child("x", "http://jabber.org/protocol/muc") then
-		module:log("debug", "Room creation without <x>, possibly desynced");
-
-		origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+module:hook("muc-occupant-pre-join", function(event)
+	local room = event.room;
+	local nick = jid_resource(event.occupant.nick);
+	if not resourceprep(nick, true) then -- strict
+		event.origin.send(st.error_reply(event.stanza, "modify", "jid-malformed", "Nickname must pass strict validation", room.jid));
 		return true;
 	end
+end, 2);
 
+module:hook("muc-occupant-pre-change", function(event)
+	local room = event.room;
+	local nick = jid_resource(event.dest_occupant.nick);
+	if not resourceprep(nick, true) then -- strict
+		event.origin.send(st.error_reply(event.stanza, "modify", "jid-malformed", "Nickname must pass strict validation", room.jid));
+		return true;
+	end
+end, 2);
+
+function room_mt:handle_first_presence(origin, stanza)
 	local real_jid = stanza.attr.from;
 	local dest_jid = stanza.attr.to;
 	local bare_jid = jid_bare(real_jid);
@@ -495,6 +549,72 @@
 	return true;
 end
 
+
+function room_mt:is_anonymous_for(jid)
+	local is_anonymous = false;
+	local whois = self:get_whois();
+	if whois ~= "anyone" then
+		local affiliation = self:get_affiliation(jid);
+		if affiliation ~= "admin" and affiliation ~= "owner" then
+			local occupant = self:get_occupant_by_real_jid(jid);
+			if not (occupant and can_see_real_jids(whois, occupant)) then
+				is_anonymous = true;
+			end
+		end
+	end
+	return is_anonymous;
+end
+
+
+function room_mt:build_unavailable_presence(from_muc_jid, to_jid)
+	local nick = jid_resource(from_muc_jid);
+	local from_jid = self:get_registered_jid(nick);
+	if (not from_jid) then
+		module:log("debug", "Received presence probe for unavailable nickname that's not registered");
+		return;
+	end
+	local is_anonymous = self:is_anonymous_for(to_jid);
+	local affiliation = self:get_affiliation(from_jid) or "none";
+	local pr = st.presence({ to = to_jid, from = from_muc_jid, type = "unavailable" })
+		:tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' })
+			:tag("item", {
+				affiliation = affiliation;
+				role = "none";
+				nick = nick;
+				jid = not is_anonymous and from_jid or nil }):up()
+			:up();
+
+	local x = pr:get_child("x", "http://jabber.org/protocol/muc");
+	local event = {
+		room = self; stanza = pr; x = x;
+		bare_jid = from_jid;
+		nick = nick;
+	}
+	module:fire_event("muc-build-occupant-presence", event);
+	return event.stanza;
+end
+
+function room_mt:respond_to_probe(origin, stanza, probing_occupant)
+	if probing_occupant == nil then
+		origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid));
+		return;
+	end
+
+	local from_muc_jid = stanza.attr.to;
+	local probed_occupant = self:get_occupant_by_nick(from_muc_jid);
+	if probed_occupant == nil then
+		local to_jid = stanza.attr.from;
+		local pr = self:build_unavailable_presence(from_muc_jid, to_jid);
+		if pr then
+			self:route_stanza(pr);
+		end
+		return;
+	end
+	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"});
+	self:publicise_occupant_status(probed_occupant, x, nil, nil, nil, nil, false, probing_occupant);
+end
+
+
 function room_mt:handle_normal_presence(origin, stanza)
 	local type = stanza.attr.type;
 	local real_jid = stanza.attr.from;
@@ -505,7 +625,7 @@
 	if orig_occupant == nil and not muc_x and stanza.attr.type == nil then
 		module:log("debug", "Attempted join without <x>, possibly desynced");
 		origin.send(st.error_reply(stanza, "cancel", "item-not-found",
-			"You must join the room before sending presence updates"));
+			"You are not currently connected to this chat", self.jid));
 		return true;
 	end
 
@@ -514,6 +634,9 @@
 	if type == "unavailable" then
 		if orig_occupant == nil then return true; end -- Unavailable from someone not in the room
 		-- dest_occupant = nil
+	elseif type == "probe" then
+		self:respond_to_probe(origin, stanza, orig_occupant)
+		return true;
 	elseif orig_occupant and orig_occupant.nick == stanza.attr.to then -- Just a presence update
 		log("debug", "presence update for %s from session %s", orig_occupant.nick, real_jid);
 		dest_occupant = orig_occupant;
@@ -567,15 +690,15 @@
 		and bare_jid ~= jid_bare(dest_occupant.bare_jid) then
 		-- new nick or has different bare real jid
 		log("debug", "%s couldn't join due to nick conflict: %s", real_jid, dest_occupant.nick);
-		local reply = st.error_reply(stanza, "cancel", "conflict"):up();
-		reply.tags[1].attr.code = "409";
-		origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		local reply = st.error_reply(stanza, "cancel", "conflict", nil, self.jid):up();
+		origin.send(reply);
 		return true;
 	end
 
 	-- Send presence stanza about original occupant
 	if orig_occupant ~= nil and orig_occupant ~= dest_occupant then
 		local orig_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+		local orig_role = orig_occupant.role;
 		local dest_nick;
 		if dest_occupant == nil then -- Session is leaving
 			log("debug", "session %s is leaving occupant %s", real_jid, orig_occupant.nick);
@@ -613,12 +736,12 @@
 				x:tag("status", {code = "303";}):up();
 				x:tag("status", {code = "110";}):up();
 				self:route_stanza(generated_unavail:add_child(x));
-				dest_nick = nil; -- set dest_nick to nil; so general populance doesn't see it for whole orig_occupant
+				dest_nick = nil; -- set dest_nick to nil; so general populace doesn't see it for whole orig_occupant
 			end
 		end
 
 		self:save_occupant(orig_occupant);
-		self:publicise_occupant_status(orig_occupant, orig_x, dest_nick);
+		self:publicise_occupant_status(orig_occupant, orig_x, dest_nick, nil, nil, orig_role);
 
 		if is_last_orig_session then
 			module:fire_event("muc-occupant-left", {
@@ -639,7 +762,7 @@
 			-- Send occupant list to newly joined or desynced user
 			self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212
 				-- Don't include self
-				return occupant:get_presence(real_jid) == nil;
+				return (not occupant) or occupant:get_presence(real_jid) == nil;
 			end)
 		end
 		local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
@@ -650,7 +773,7 @@
 		if nick_changed then
 			self_x:tag("status", {code="210"}):up();
 		end
-		self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x});
+		self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x}, nil, nil, nil, orig_occupant and orig_occupant.role or nil);
 
 		if orig_occupant ~= nil and orig_occupant ~= dest_occupant and not is_last_orig_session then
 			-- If user is swapping and wasn't last original session
@@ -692,11 +815,11 @@
 	local type = stanza.attr.type;
 	if type == "error" then -- error, kick em out!
 		return self:handle_kickable(origin, stanza)
-	elseif type == nil or type == "unavailable" then
+	elseif type == nil or type == "unavailable" or type == "probe" then
 		return self:handle_normal_presence(origin, stanza);
 	elseif type ~= 'result' then -- bad type
 		if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences
-			origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error?
+			origin.send(st.error_reply(stanza, "modify", "bad-request", nil, self.jid)); -- FIXME correct error?
 		end
 	end
 	return true;
@@ -715,8 +838,9 @@
 			local from_occupant_jid = self:get_occupant_jid(from_jid);
 			if from_occupant_jid == nil then return nil; end
 			local session_jid
+			local salt = self:get_salt();
 			for to_jid in occupant:each_session() do
-				if md5(to_jid) == to_jid_hash then
+				if hmac_sha256(salt, to_jid):sub(1,8) == to_jid_hash then
 					session_jid = to_jid;
 					break;
 				end
@@ -731,11 +855,11 @@
 	else -- Type is "get" or "set"
 		local current_nick = self:get_occupant_jid(from);
 		if not current_nick then
-			origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat"));
+			origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid));
 			return true;
 		end
 		if not occupant then -- recipient not in room
-			origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
+			origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room", self.jid));
 			return true;
 		end
 		-- XEP-0410 MUC Self-Ping #1220
@@ -744,7 +868,8 @@
 			return true;
 		end
 		do -- construct_stanza_id
-			stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..md5(from));
+			local salt = self:get_salt();
+			stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..hmac_sha256(salt, from):sub(1,8));
 		end
 		stanza.attr.from, stanza.attr.to = current_nick, occupant.jid;
 		log("debug", "%s sent private iq stanza to %s (%s)", from, to, occupant.jid);
@@ -764,12 +889,12 @@
 	local type = stanza.attr.type;
 	if not current_nick then -- not in room
 		if type ~= "error" then
-			origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat"));
+			origin.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not currently connected to this chat", self.jid));
 		end
 		return true;
 	end
 	if type == "groupchat" then -- groupchat messages not allowed in PM
-		origin.send(st.error_reply(stanza, "modify", "bad-request"));
+		origin.send(st.error_reply(stanza, "modify", "bad-request", nil, self.jid));
 		return true;
 	elseif type == "error" and is_kickable_error(stanza) then
 		log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid);
@@ -778,14 +903,16 @@
 
 	local o_data = self:get_occupant_by_nick(to);
 	if not o_data then
-		origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
+		origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room", self.jid));
 		return true;
 	end
 	log("debug", "%s sent private message stanza to %s (%s)", from, to, o_data.jid);
 	stanza = muc_util.filter_muc_x(st.clone(stanza));
 	stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up();
 	stanza.attr.from = current_nick;
-	self:route_to_occupant(o_data, stanza)
+	if module:fire_event("muc-private-message", { room = self, origin = origin, stanza = stanza }) ~= false then
+		self:route_to_occupant(o_data, stanza)
+	end
 	-- TODO: Remove x tag?
 	stanza.attr.from = from;
 	return true;
@@ -815,10 +942,12 @@
 	if form.attr.type == "cancel" then
 		origin.send(st.reply(stanza));
 	elseif form.attr.type == "submit" then
+		-- luacheck: ignore 231/errors
 		local fields, errors, present;
 		if form.tags[1] == nil then -- Instant room
 			fields, present = {}, {};
 		else
+			-- FIXME handle form errors
 			fields, errors, present = self:get_form_layout(stanza.attr.from):data(form);
 			if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then
 				origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration"));
@@ -873,19 +1002,28 @@
 	x = x or st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
 	local occupants_updated = {};
 	for nick, occupant in self:each_occupant() do -- luacheck: ignore 213
+		local prev_role = occupant.role;
 		occupant.role = nil;
 		self:save_occupant(occupant);
-		occupants_updated[occupant] = true;
+		occupants_updated[occupant] = prev_role;
 	end
-	for occupant in pairs(occupants_updated) do
-		self:publicise_occupant_status(occupant, x);
-		module:fire_event("muc-occupant-left", { room = self; nick = occupant.nick; occupant = occupant;});
+	for occupant, prev_role in pairs(occupants_updated) do
+		self:publicise_occupant_status(occupant, x, nil, nil, nil, prev_role);
+		module:fire_event("muc-occupant-left", {
+				room = self;
+				nick = occupant.nick;
+				occupant = occupant;
+			});
 	end
 end
 
 function room_mt:destroy(newjid, reason, password)
-	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"})
-		:tag("destroy", {jid=newjid});
+	local x = st.stanza("x", { xmlns = "http://jabber.org/protocol/muc#user" });
+	local event = { room = self; newjid = newjid; reason = reason; password = password; x = x, allowed = true };
+	module:fire_event("muc-pre-room-destroy", event);
+	if not event.allowed then return false, event.error; end
+	newjid, reason, password = event.newjid, event.reason, event.password;
+	x:tag("destroy", { jid = newjid });
 	if reason then x:tag("reason"):text(reason):up(); end
 	if password then x:tag("password"):text(password):up(); end
 	x:up();
@@ -916,6 +1054,9 @@
 		if not item.attr.jid then
 			origin.send(st.error_reply(stanza, "modify", "jid-malformed"));
 			return true;
+		elseif jid_resource(item.attr.jid) then
+			origin.send(st.error_reply(stanza, "modify", "jid-malformed", "Bare JID expected, got full JID"));
+			return true;
 		end
 	end
 	if item.attr.nick then -- Validate provided nick
@@ -972,7 +1113,7 @@
 	local _aff_rank = valid_affiliations[_aff or "none"];
 	local _rol = item.attr.role;
 	if _aff and _aff_rank and not _rol then
-		-- You need to be at least an admin, and be requesting info about your affifiliation or lower
+		-- You need to be at least an admin, and be requesting info about your affiliation or lower
 		-- e.g. an admin can't ask for a list of owners
 		local affiliation_rank = valid_affiliations[affiliation or "none"];
 		if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank)
@@ -1035,8 +1176,12 @@
 		local newjid = child.attr.jid;
 		local reason = child:get_child_text("reason");
 		local password = child:get_child_text("password");
-		self:destroy(newjid, reason, password);
-		origin.send(st.reply(stanza));
+		local destroyed, err = self:destroy(newjid, reason, password);
+		if destroyed then
+			origin.send(st.reply(stanza));
+		else
+			origin.send(st.error_reply(stanza, err or "cancel", "not-allowed"));
+		end
 		return true;
 	elseif child.name == "x" and child.attr.xmlns == "jabber:x:data" then
 		return self:process_form(origin, stanza);
@@ -1049,10 +1194,18 @@
 function room_mt:handle_groupchat_to_room(origin, stanza)
 	local from = stanza.attr.from;
 	local occupant = self:get_occupant_by_real_jid(from);
-	if module:fire_event("muc-occupant-groupchat", {
-		room = self; origin = origin; stanza = stanza; from = from; occupant = occupant;
-	}) then return true; end
-	stanza.attr.from = occupant.nick;
+	if not stanza.attr.id then
+		stanza.attr.id = new_id()
+	end
+	local event_data = {room = self; origin = origin; stanza = stanza; from = from; occupant = occupant};
+	if module:fire_event("muc-occupant-groupchat", event_data) then
+		return true;
+	end
+	if event_data.occupant then
+		stanza.attr.from = event_data.occupant.nick;
+	else
+		stanza.attr.from = self.jid;
+	end
 	self:broadcast_message(stanza);
 	stanza.attr.from = from;
 	return true;
@@ -1065,7 +1218,8 @@
 		event.origin.send(st.error_reply(event.stanza, "cancel", "not-acceptable", "You are not currently connected to this chat"));
 		return true;
 	elseif role_rank <= valid_roles.visitor then
-		event.origin.send(st.error_reply(event.stanza, "auth", "forbidden"));
+		event.origin.send(st.error_reply(event.stanza, "auth", "forbidden",
+			"You do not currently have permission to speak in this chat"));
 		return true;
 	end
 end, 50);
@@ -1218,7 +1372,7 @@
 end
 
 function room_mt:get_affiliation(jid)
-	local node, host, resource = jid_split(jid);
+	local node, host = jid_split(jid);
 	-- Affiliations are granted, revoked, and maintained based on the user's bare JID.
 	local bare = node and node.."@"..host or host;
 	local result = self._affiliations[bare];
@@ -1241,7 +1395,7 @@
 function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
 	if not actor then return nil, "modify", "not-acceptable"; end;
 
-	local node, host, resource = jid_split(jid);
+	local node, host = jid_split(jid);
 	if not host then return nil, "modify", "not-acceptable"; end
 	jid = jid_join(node, host); -- Bare
 	local is_host_only = node == nil;
@@ -1278,6 +1432,27 @@
 		end
 	end
 
+	local event_data = {
+		room = self;
+		actor = actor;
+		jid = jid;
+		affiliation = affiliation or "none";
+		reason = reason;
+		previous_affiliation = target_affiliation or "none";
+		data = data and data or nil; -- coerce false to nil
+		previous_data = self._affiliation_data[jid] or nil;
+	};
+
+	module:fire_event("muc-pre-set-affiliation", event_data);
+	if event_data.allowed == false then
+		local err = event_data.error or { type = "cancel", condition = "not-allowed" };
+		return nil, err.type, err.condition;
+	end
+	if affiliation and not data and event_data.data then
+		-- Allow handlers to add data when none was going to be set
+		data = event_data.data;
+	end
+
 	-- Set in 'database'
 	self._affiliations[jid] = affiliation;
 	if not affiliation or data == false or (data ~= nil and next(data) == nil) then
@@ -1297,7 +1472,7 @@
 			-- Outcast can be by host.
 			is_host_only and affiliation == "outcast" and select(2, jid_split(occupant.bare_jid)) == host
 		) then
-			-- need to publcize in all cases; as affiliation in <item/> has changed.
+			-- need to publicize in all cases; as affiliation in <item/> has changed.
 			occupants_updated[occupant] = occupant.role;
 			if occupant.role ~= role and (
 				is_downgrade or
@@ -1322,16 +1497,20 @@
 
 	if next(occupants_updated) ~= nil then
 		for occupant, old_role in pairs(occupants_updated) do
-			self:publicise_occupant_status(occupant, x, nil, actor, reason);
+			self:publicise_occupant_status(occupant, x, nil, actor, reason, old_role);
 			if occupant.role == nil then
-				module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+				module:fire_event("muc-occupant-left", {
+						room = self;
+						nick = occupant.nick;
+						occupant = occupant;
+					});
 			elseif is_semi_anonymous and
 				((old_role == "moderator" and occupant.role ~= "moderator") or
 				(old_role ~= "moderator" and occupant.role == "moderator")) then -- Has gained or lost moderator status
 				-- Send everyone else's presences (as jid visibility has changed)
 				for real_jid in occupant:each_session() do
 					self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433
-						return occupant.bare_jid ~= jid;
+						return (not occupant) or occupant.bare_jid ~= jid;
 					end);
 				end
 			end
@@ -1348,16 +1527,8 @@
 
 	self:save(true);
 
-	module:fire_event("muc-set-affiliation", {
-		room = self;
-		actor = actor;
-		jid = jid;
-		affiliation = affiliation or "none";
-		reason = reason;
-		previous_affiliation = target_affiliation;
-		data = data and data or nil; -- coerce false to nil
-		in_room = next(occupants_updated) ~= nil;
-	});
+	event_data.in_room = next(occupants_updated) ~= nil;
+	module:fire_event("muc-set-affiliation", event_data);
 
 	return true;
 end
@@ -1371,11 +1542,71 @@
 	return data;
 end
 
+function room_mt:set_affiliation_data(jid, key, value)
+	if key == nil then return nil, "invalid key"; end
+	local data = self._affiliation_data[jid];
+	if not data then
+		if value == nil then return true; end
+		data = {};
+		self._affiliation_data[jid] = data;
+	end
+	local old_value = data[key];
+	data[key] = value;
+	if old_value ~= value then
+		module:fire_event("muc-set-affiliation-data/"..key, {
+			room = self;
+			jid = jid;
+			key = key;
+			value = value;
+			old_value = old_value;
+		});
+	end
+	self:save(true);
+	return true;
+end
+
 function room_mt:get_role(nick)
 	local occupant = self:get_occupant_by_nick(nick);
 	return occupant and occupant.role or nil;
 end
 
+function room_mt:may_set_role(actor, occupant, role)
+	local event = {
+		room = self,
+		actor = actor,
+		occupant = occupant,
+		role = role,
+	};
+
+	module:fire_event("muc-pre-set-role", event);
+	if event.allowed ~= nil then
+		return event.allowed, event.error, event.condition;
+	end
+
+	local actor_affiliation = self:get_affiliation(actor) or "none";
+	local occupant_affiliation = self:get_affiliation(occupant.bare_jid) or "none";
+
+	-- Can't do anything to someone with higher affiliation
+	if valid_affiliations[actor_affiliation] < valid_affiliations[occupant_affiliation] then
+		return nil, "cancel", "not-allowed";
+	end
+
+	-- If you are trying to give or take moderator role you need to be an owner or admin
+	if occupant.role == "moderator" or role == "moderator" then
+		if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then
+			return nil, "cancel", "not-allowed";
+		end
+	end
+
+	-- Need to be in the room and a moderator
+	local actor_occupant = self:get_occupant_by_real_jid(actor);
+	if not actor_occupant or actor_occupant.role ~= "moderator" then
+		return nil, "cancel", "not-allowed";
+	end
+
+	return true;
+end
+
 function room_mt:set_role(actor, occupant_jid, role, reason)
 	if not actor then return nil, "modify", "not-acceptable"; end
 
@@ -1390,24 +1621,9 @@
 	if actor == true then
 		actor = nil -- So we can pass it safely to 'publicise_occupant_status' below
 	else
-		-- Can't do anything to other owners or admins
-		local occupant_affiliation = self:get_affiliation(occupant.bare_jid);
-		if occupant_affiliation == "owner" or occupant_affiliation == "admin" then
-			return nil, "cancel", "not-allowed";
-		end
-
-		-- If you are trying to give or take moderator role you need to be an owner or admin
-		if occupant.role == "moderator" or role == "moderator" then
-			local actor_affiliation = self:get_affiliation(actor);
-			if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then
-				return nil, "cancel", "not-allowed";
-			end
-		end
-
-		-- Need to be in the room and a moderator
-		local actor_occupant = self:get_occupant_by_real_jid(actor);
-		if not actor_occupant or actor_occupant.role ~= "moderator" then
-			return nil, "cancel", "not-allowed";
+		local allowed, err, condition = self:may_set_role(actor, occupant, role)
+		if not allowed then
+			return allowed, err, condition;
 		end
 	end
 
@@ -1415,11 +1631,17 @@
 	if not role then
 		x:tag("status", {code = "307"}):up();
 	end
+
+	local prev_role = occupant.role;
 	occupant.role = role;
 	self:save_occupant(occupant);
-	self:publicise_occupant_status(occupant, x, nil, actor, reason);
+	self:publicise_occupant_status(occupant, x, nil, actor, reason, prev_role);
 	if role == nil then
-		module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+		module:fire_event("muc-occupant-left", {
+				room = self;
+				nick = occupant.nick;
+				occupant = occupant;
+			});
 	end
 	return true;
 end
@@ -1441,7 +1663,7 @@
 	}, room_mt);
 end
 
-local new_format = module:get_option_boolean("new_muc_storage_format", false);
+local new_format = module:get_option_boolean("new_muc_storage_format", true);
 
 function room_mt:freeze(live)
 	local frozen, state;
@@ -1505,7 +1727,7 @@
 	else
 		-- New storage format
 		for jid, data in pairs(frozen) do
-			local node, host, resource = jid_split(jid);
+			local _, host, resource = jid_split(jid);
 			if host:sub(1,1) ~= "_" and not resource and type(data) == "string" then
 				-- bare jid: affiliation
 				room._affiliations[jid] = data;
--- a/plugins/muc/name.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/name.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,10 +7,8 @@
 -- COPYING file in the source package for more information.
 --
 
-local jid_split = require "util.jid".split;
-
 local function get_name(room)
-	return room._data.name or jid_split(room.jid);
+	return room._data.name;
 end
 
 local function set_name(room, name)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/occupant_id.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,76 @@
+-- Implementation of https://xmpp.org/extensions/inbox/occupant-id.html
+-- XEP-0421: Anonymous unique occupant identifiers for MUCs
+
+-- (C) 2020 Maxime “pep” Buquet <pep@bouah.net>
+-- (C) 2020 Matthew Wild <mwild1@gmail.com>
+
+local uuid = require "util.uuid";
+local hmac_sha256 = require "util.hashes".hmac_sha256;
+local b64encode = require "util.encodings".base64.encode;
+
+local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
+
+local function get_room_salt(room)
+	local salt = room._data.occupant_id_salt;
+	if not salt then
+		salt = uuid.generate();
+		room._data.occupant_id_salt = salt;
+	end
+	return salt;
+end
+
+local function get_occupant_id(room, occupant)
+	if occupant.stable_id then
+		return occupant.stable_id;
+	end
+
+	local salt = get_room_salt(room)
+
+	occupant.stable_id = b64encode(hmac_sha256(occupant.bare_jid, salt));
+
+	return occupant.stable_id;
+end
+
+local function update_occupant(event)
+	local stanza, room, occupant, dest_occupant = event.stanza, event.room, event.occupant, event.dest_occupant;
+
+	-- "muc-occupant-pre-change" provides "dest_occupant" but not "occupant".
+	if dest_occupant ~= nil then
+		occupant = dest_occupant;
+	end
+
+	-- strip any existing <occupant-id/> tags to avoid forgery
+	stanza:remove_children("occupant-id", xmlns_occupant_id);
+
+	local unique_id = get_occupant_id(room, occupant);
+	stanza:tag("occupant-id", { xmlns = xmlns_occupant_id, id = unique_id }):up();
+end
+
+local function muc_private(event)
+	local stanza, room = event.stanza, event.room;
+	local occupant = room._occupants[stanza.attr.from];
+
+	update_occupant({
+		stanza = stanza,
+		room = room,
+		occupant = occupant,
+	});
+end
+
+if module:get_option_boolean("muc_occupant_id", true) then
+	module:add_feature(xmlns_occupant_id);
+	module:hook("muc-disco#info", function (event)
+		event.reply:tag("feature", { var = xmlns_occupant_id }):up();
+	end);
+
+	module:hook("muc-broadcast-presence", update_occupant);
+	module:hook("muc-occupant-pre-join", update_occupant);
+	module:hook("muc-occupant-pre-change", update_occupant);
+	module:hook("muc-occupant-groupchat", update_occupant);
+	module:hook("muc-private-message", muc_private);
+end
+
+return {
+	get_room_salt = get_room_salt;
+	get_occupant_id = get_occupant_id;
+};
--- a/plugins/muc/password.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/password.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -50,9 +50,8 @@
 	if get_password(room) ~= password then
 		local from, to = stanza.attr.from, stanza.attr.to;
 		module:log("debug", "%s couldn't join due to invalid password: %s", from, to);
-		local reply = st.error_reply(stanza, "auth", "not-authorized"):up();
-		reply.tags[1].attr.code = "401";
-		event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		local reply = st.error_reply(stanza, "auth", "not-authorized", nil, room.jid):up();
+		event.origin.send(reply);
 		return true;
 	end
 end, -20);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/presence_broadcast.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,83 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local valid_roles = { "none", "visitor", "participant", "moderator" };
+local default_broadcast = {
+	visitor = true;
+	participant = true;
+	moderator = true;
+};
+
+local function get_presence_broadcast(room)
+	return room._data.presence_broadcast or default_broadcast;
+end
+
+local function set_presence_broadcast(room, broadcast_roles)
+	broadcast_roles = broadcast_roles or default_broadcast;
+
+	local changed = false;
+	local old_broadcast_roles = get_presence_broadcast(room);
+	for _, role in ipairs(valid_roles) do
+		if old_broadcast_roles[role] ~= broadcast_roles[role] then
+			changed = true;
+		end
+	end
+
+	if not changed then return false; end
+
+	room._data.presence_broadcast = broadcast_roles;
+
+	for _, occupant in room:each_occupant() do
+		local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+		local role = occupant.role or "none";
+		if broadcast_roles[role] and not old_broadcast_roles[role] then
+			-- Presence broadcast is now enabled, so announce existing user
+			room:publicise_occupant_status(occupant, x);
+		elseif old_broadcast_roles[role] and not broadcast_roles[role] then
+			-- Presence broadcast is now disabled, so mark existing user as unavailable
+			room:publicise_occupant_status(occupant, x, nil, nil, nil, nil, true);
+		end
+	end
+
+	return true;
+end
+
+module:hook("muc-config-form", function(event)
+	local values = {};
+	for role, value in pairs(get_presence_broadcast(event.room)) do
+		if value then
+			values[#values + 1] = role;
+		end
+	end
+
+	table.insert(event.form, {
+		name = "muc#roomconfig_presencebroadcast";
+		type = "list-multi";
+		label = "Only show participants with roles:";
+		value = values;
+		options = valid_roles;
+	});
+end, 70-7);
+
+module:hook("muc-config-submitted/muc#roomconfig_presencebroadcast", function(event)
+	local broadcast_roles = {};
+	for _, role in ipairs(event.value) do
+		broadcast_roles[role] = true;
+	end
+	if set_presence_broadcast(event.room, broadcast_roles) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+return {
+	get = get_presence_broadcast;
+	set = set_presence_broadcast;
+};
--- a/plugins/muc/register.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/register.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,6 +8,10 @@
 
 local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false);
 
+-- Whether to include the current registration data as a dataform. Disabled
+-- by default currently as it hasn't been widely tested with clients.
+local include_reg_form = module:get_option_boolean("muc_registration_include_form", false);
+
 -- reserved_nicks[nick] = jid
 local function get_reserved_nicks(room)
 	if room._reserved_nicks then
@@ -15,8 +19,7 @@
 	end
 	module:log("debug", "Refreshing reserved nicks...");
 	local reserved_nicks = {};
-	for jid in room:each_affiliation() do
-		local data = room._affiliation_data[jid];
+	for jid, _, data in room:each_affiliation() do
 		local nick = data and data.reserved_nickname;
 		module:log("debug", "Refreshed for %s: %s", jid, nick);
 		if nick then
@@ -54,9 +57,23 @@
 
 local registration_form = dataforms.new {
 	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#register" },
-	{ name = "muc#register_roomnick", type = "text-single", label = "Nickname"},
+	{ name = "muc#register_roomnick", type = "text-single", required = true, label = "Nickname"},
 };
 
+module:handle_items("muc-registration-field", function (event)
+	module:log("debug", "Adding MUC registration form field: %s", event.item.name);
+	table.insert(registration_form, event.item);
+end, function (event)
+	module:log("debug", "Removing MUC registration form field: %s", event.item.name);
+	local removed_field_name = event.item.name;
+	for i, field in ipairs(registration_form) do
+		if field.name == removed_field_name then
+			table.remove(registration_form, i);
+			break;
+		end
+	end
+end);
+
 local function enforce_nick_policy(event)
 	local origin, stanza = event.origin, event.stanza;
 	local room = assert(event.room); -- FIXME
@@ -67,8 +84,8 @@
 	local reserved_by = get_registered_jid(room, requested_nick);
 	if reserved_by and reserved_by ~= jid_bare(stanza.attr.from) then
 		module:log("debug", "%s attempted to use nick %s reserved by %s", stanza.attr.from, requested_nick, reserved_by);
-		local reply = st.error_reply(stanza, "cancel", "conflict"):up();
-		origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up();
+		origin.send(reply);
 		return true;
 	end
 
@@ -80,8 +97,8 @@
 				event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick;
 			elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then
 				module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick);
-				local reply = st.error_reply(stanza, "cancel", "not-acceptable"):up();
-				origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+				local reply = st.error_reply(stanza, "cancel", "not-acceptable", nil, room.jid):up();
+				origin.send(reply);
 				return true;
 			end
 		end
@@ -116,7 +133,13 @@
 		reply:query("jabber:iq:register");
 		if registered_nick then
 			reply:tag("registered"):up();
-			reply:tag("username"):text(registered_nick);
+			reply:tag("username"):text(registered_nick):up();
+			if include_reg_form then
+				local aff_data = room:get_affiliation_data(user_jid);
+				if aff_data then
+					reply:add_child(registration_form:form(aff_data, "result"));
+				end
+			end
 			origin.send(reply);
 			return true;
 		end
@@ -135,13 +158,25 @@
 			return true;
 		end
 		local form_tag = query:get_child("x", "jabber:x:data");
-		local reg_data = form_tag and registration_form:data(form_tag);
+		if not form_tag then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform"));
+			return true;
+		end
+		local form_type, err = dataforms.get_type(form_tag);
+		if not form_type then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Error with form: "..err));
+			return true;
+		elseif form_type ~= "http://jabber.org/protocol/muc#register" then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form"));
+			return true;
+		end
+		local reg_data = registration_form:data(form_tag);
 		if not reg_data then
 			origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form"));
 			return true;
 		end
 		-- Is the nickname valid?
-		local desired_nick = resourceprep(reg_data["muc#register_roomnick"]);
+		local desired_nick = resourceprep(reg_data["muc#register_roomnick"], true);
 		if not desired_nick then
 			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid Nickname"));
 			return true;
@@ -172,6 +207,13 @@
 		-- Checks passed, save the registration
 		if registered_nick ~= desired_nick then
 			local registration_data = { reserved_nickname = desired_nick };
+			module:fire_event("muc-registration-submitted", {
+				room = room;
+				origin = origin;
+				stanza = stanza;
+				submitted_data = reg_data;
+				affiliation_data = registration_data;
+			});
 			local ok, err_type, err_condition = room:set_affiliation(true, user_jid, affiliation or "member", nil, registration_data);
 			if not ok then
 				origin.send(st.error_reply(stanza, err_type, err_condition));
--- a/plugins/muc/subject.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/subject.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -94,6 +94,12 @@
 	local stanza = event.stanza;
 	local subject = stanza:get_child("subject");
 	if subject then
+		if stanza:get_child("body") or stanza:get_child("thread") then
+			-- Note: A message with a <subject/> and a <body/> or a <subject/> and
+			-- a <thread/> is a legitimate message, but it SHALL NOT be interpreted
+			-- as a subject change.
+			return;
+		end
 		local room = event.room;
 		local occupant = event.occupant;
 		-- Role check for subject changes
--- a/plugins/muc/util.lib.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/plugins/muc/util.lib.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -41,18 +41,22 @@
 	return kickable_error_conditions[cond];
 end
 
-local muc_x_filters = {
-	["http://jabber.org/protocol/muc"] = true;
-	["http://jabber.org/protocol/muc#user"] = true;
-}
-local function muc_x_filter(tag)
-	if muc_x_filters[tag.attr.xmlns] then
+local filtered_namespaces = module:shared("filtered-namespaces");
+filtered_namespaces["http://jabber.org/protocol/muc"] = true;
+filtered_namespaces["http://jabber.org/protocol/muc#user"] = true;
+
+local function muc_ns_filter(tag)
+	if filtered_namespaces[tag.attr.xmlns] then
 		return nil;
 	end
 	return tag;
 end
 function _M.filter_muc_x(stanza)
-	return stanza:maptags(muc_x_filter);
+	return stanza:maptags(muc_ns_filter);
+end
+
+function _M.add_filtered_namespace(xmlns)
+	filtered_namespaces[xmlns] = true;
 end
 
 function _M.only_with_min_role(role)
--- a/prosody	Mon Dec 12 07:03:31 2022 +0100
+++ b/prosody	Mon Dec 12 07:07:13 2022 +0100
@@ -54,6 +54,8 @@
 
 thread:run(startup.prosody);
 
+prosody.main_thread = thread;
+
 local function loop()
 	-- Error handler for errors that make it this far
 	local function catch_uncaught_error(err)
@@ -78,16 +80,6 @@
 	end
 end
 
-local function cleanup()
-	prosody.log("info", "Shutdown status: Cleaning up");
-	prosody.events.fire_event("server-cleanup");
-end
-
 loop();
 
-prosody.log("info", "Shutting down...");
-cleanup();
-prosody.events.fire_event("server-stopped");
-prosody.log("info", "Shutdown complete");
-
-os.exit(prosody.shutdown_code);
+startup.exit();
--- a/prosody.cfg.lua.dist	Mon Dec 12 07:03:31 2022 +0100
+++ b/prosody.cfg.lua.dist	Mon Dec 12 07:07:13 2022 +0100
@@ -23,63 +23,67 @@
 -- Example: admins = { "user1@example.com", "user2@example.net" }
 admins = { }
 
--- Enable use of libevent for better performance under high load
--- For more information see: https://prosody.im/doc/libevent
---use_libevent = true
-
--- Prosody will always look in its source directory for modules, but
--- this option allows you to specify additional locations where Prosody
--- will look for modules first. For community modules, see https://modules.prosody.im/
+-- This option allows you to specify additional locations where Prosody
+-- will search first for modules. For additional modules you can install, see
+-- the community module repository at https://modules.prosody.im/
 --plugin_paths = {}
 
 -- This is the list of modules Prosody will load on startup.
--- It looks for mod_modulename.lua in the plugins folder, so make sure that exists too.
 -- Documentation for bundled modules can be found at: https://prosody.im/doc/modules
 modules_enabled = {
 
 	-- Generally required
+		"disco"; -- Service discovery
 		"roster"; -- Allow users to have a roster. Recommended ;)
 		"saslauth"; -- Authentication for clients and servers. Recommended if you want to log in.
 		"tls"; -- Add support for secure TLS on c2s/s2s connections
-		"dialback"; -- s2s dialback support
-		"disco"; -- Service discovery
 
 	-- Not essential, but recommended
-		"carbons"; -- Keep multiple clients in sync
-		"pep"; -- Enables users to publish their avatar, mood, activity, playing music and more
-		"private"; -- Private XML storage (for room bookmarks, etc.)
 		"blocklist"; -- Allow users to block communications with other users
+		"bookmarks"; -- Synchronise the list of open rooms between clients
+		"carbons"; -- Keep multiple online clients in sync
+		"dialback"; -- Support for verifying remote servers using DNS
+		"limits"; -- Enable bandwidth limiting for XMPP connections
+		"pep"; -- Allow users to store public and private data in their account
+		"private"; -- Legacy account storage mechanism (XEP-0049)
+		"smacks"; -- Stream management and resumption (XEP-0198)
 		"vcard4"; -- User profiles (stored in PEP)
 		"vcard_legacy"; -- Conversion between legacy vCard and PEP Avatar, vcard
-		"limits"; -- Enable bandwidth limiting for XMPP connections
 
 	-- Nice to have
-		"version"; -- Replies to server version requests
-		"uptime"; -- Report how long server has been running
-		"time"; -- Let others know the time here on this server
+		"csi_simple"; -- Simple but effective traffic optimizations for mobile devices
+		"invites"; -- Create and manage invites
+		"invites_adhoc"; -- Allow admins/users to create invitations via their client
+		"invites_register"; -- Allows invited users to create accounts
 		"ping"; -- Replies to XMPP pings with pongs
 		"register"; -- Allow users to register on this server using a client and change passwords
-		--"mam"; -- Store messages in an archive and allow users to access it
-		--"csi_simple"; -- Simple Mobile optimizations
+		"time"; -- Let others know the time here on this server
+		"uptime"; -- Report how long server has been running
+		"version"; -- Replies to server version requests
+		--"mam"; -- Store recent messages to allow multi-device synchronization
+		--"turn_external"; -- Provide external STUN/TURN service for e.g. audio/video calls
 
 	-- Admin interfaces
 		"admin_adhoc"; -- Allows administration via an XMPP client that supports ad-hoc commands
-		--"admin_telnet"; -- Opens telnet console interface on localhost port 5582
+		"admin_shell"; -- Allow secure administration via 'prosodyctl shell'
 
 	-- HTTP modules
 		--"bosh"; -- Enable BOSH clients, aka "Jabber over HTTP"
+		--"http_openmetrics"; -- for exposing metrics to stats collectors
 		--"websocket"; -- XMPP over WebSockets
-		--"http_files"; -- Serve static files from a directory over HTTP
 
 	-- Other specific functionality
+		--"announce"; -- Send announcement to all online users
 		--"groups"; -- Shared roster support
+		--"legacyauth"; -- Legacy authentication. Only used by some old clients and bots.
+		--"mimicking"; -- Prevent address spoofing
+		--"motd"; -- Send a message to users when they log in
+		--"proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
+		--"s2s_bidi"; -- Bi-directional server-to-server (XEP-0288)
 		--"server_contact_info"; -- Publish contact information for this service
-		--"announce"; -- Send announcement to all online users
+		--"tombstones"; -- Prevent registration of deleted accounts
+		--"watchregistrations"; -- Alert admins of registrations
 		--"welcome"; -- Welcome users who register accounts
-		--"watchregistrations"; -- Alert admins of registrations
-		--"motd"; -- Send a message to users when they log in
-		--"legacyauth"; -- Legacy authentication. Only used by some old clients and bots.
-		--"proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
 }
 
 -- These modules are auto-loaded, but should you want
@@ -88,31 +92,20 @@
 	-- "offline"; -- Store offline messages
 	-- "c2s"; -- Handle client connections
 	-- "s2s"; -- Handle server-to-server connections
-	-- "posix"; -- POSIX functionality, sends server to background, enables syslog, etc.
+	-- "posix"; -- POSIX functionality, sends server to background, etc.
 }
 
--- Disable account creation by default, for security
--- For more information see https://prosody.im/doc/creating_accounts
-allow_registration = false
-
--- Force clients to use encrypted connections? This option will
--- prevent clients from authenticating unless they are using encryption.
-
-c2s_require_encryption = true
 
--- Force servers to use encrypted connections? This option will
--- prevent servers from authenticating unless they are using encryption.
+-- Server-to-server authentication
+-- Require valid certificates for server-to-server connections?
+-- If false, other methods such as dialback (DNS) may be used instead.
 
-s2s_require_encryption = true
-
--- Force certificate authentication for server-to-server connections?
-
-s2s_secure_auth = false
+s2s_secure_auth = true
 
 -- Some servers have invalid or self-signed certificates. You can list
 -- remote domains here that will not be required to authenticate using
--- certificates. They will be authenticated using DNS instead, even
--- when s2s_secure_auth is enabled.
+-- certificates. They will be authenticated using other methods instead,
+-- even when s2s_secure_auth is enabled.
 
 --s2s_insecure_domains = { "insecure.example" }
 
@@ -121,22 +114,33 @@
 
 --s2s_secure_domains = { "jabber.org" }
 
--- Enable rate limits for incoming client and server connections
+
+-- Rate limits
+-- Enable rate limits for incoming client and server connections. These help
+-- protect from excessive resource consumption and denial-of-service attacks.
 
 limits = {
-  c2s = {
-    rate = "10kb/s";
-  };
-  s2sin = {
-    rate = "30kb/s";
-  };
+	c2s = {
+		rate = "10kb/s";
+	};
+	s2sin = {
+		rate = "30kb/s";
+	};
 }
 
+-- Authentication
 -- Select the authentication backend to use. The 'internal' providers
 -- use Prosody's configured data storage to store the authentication data.
+-- For more information see https://prosody.im/doc/authentication
 
 authentication = "internal_hashed"
 
+-- Many authentication providers, including the default one, allow you to
+-- create user accounts via Prosody's admin interfaces. For details, see the
+-- documentation at https://prosody.im/doc/creating_accounts
+
+
+-- Storage
 -- Select the storage backend to use. By default Prosody uses flat files
 -- in its configured data directory, but it also supports more backends
 -- through modules. An "sql" backend is included by default, but requires
@@ -161,19 +165,36 @@
 -- You can also configure messages to be stored in-memory only. For more
 -- archiving options, see https://prosody.im/doc/modules/mod_mam
 
+
+-- Audio/video call relay (STUN/TURN)
+-- To ensure clients connected to the server can establish connections for
+-- low-latency media streaming (such as audio and video calls), it is
+-- recommended to run a STUN/TURN server for clients to use. If you do this,
+-- specify the details here so clients can discover it.
+-- Find more information at https://prosody.im/doc/turn
+
+-- Specify the address of the TURN service (you may use the same domain as XMPP)
+--turn_external_host = "turn.example.com"
+
+-- This secret must be set to the same value in both Prosody and the TURN server
+--turn_external_secret = "your-secret-turn-access-token"
+
+
 -- Logging configuration
 -- For advanced logging see https://prosody.im/doc/logging
 log = {
 	info = "prosody.log"; -- Change 'info' to 'debug' for verbose logging
 	error = "prosody.err";
 	-- "*syslog"; -- Uncomment this for logging to syslog
-	-- "*console"; -- Log to the console, useful for debugging with daemonize=false
+	-- "*console"; -- Log to the console, useful for debugging when running in the foreground
 }
 
+
 -- Uncomment to enable statistics
 -- For more info see https://prosody.im/doc/statistics
 -- statistics = "internal"
 
+
 -- Certificates
 -- Every virtual host and component needs a certificate so that clients and
 -- servers can securely verify its identity. Prosody will automatically load
@@ -184,17 +205,16 @@
 -- Location of directory to find certificates in (relative to main config file):
 certificates = "certs"
 
--- HTTPS currently only supports a single certificate, specify it here:
---https_certificate = "certs/localhost.crt"
-
 ----------- Virtual hosts -----------
 -- You need to add a VirtualHost entry for each domain you wish Prosody to serve.
 -- Settings under each VirtualHost entry apply *only* to that host.
 
 VirtualHost "localhost"
+-- Prosody requires at least one enabled VirtualHost to function. You can
+-- safely remove or disable 'localhost' once you have added another.
+
 
 --VirtualHost "example.com"
---	certificate = "/path/to/example.crt"
 
 ------ Components ------
 -- You can specify components to add hosts that provide special services,
@@ -206,11 +226,25 @@
 --- Store MUC messages in an archive and allow users to access it
 --modules_enabled = { "muc_mam" }
 
+---Set up a file sharing component
+--Component "share.example.com" "http_file_share"
+
 ---Set up an external component (default component port is 5347)
 --
 -- External components allow adding various services, such as gateways/
--- transports to other networks like ICQ, MSN and Yahoo. For more info
+-- bridges to non-XMPP networks and services. For more info
 -- see: https://prosody.im/doc/components#adding_an_external_component
 --
 --Component "gateway.example.com"
 --	component_secret = "password"
+
+
+---------- End of the Prosody Configuration file ----------
+-- You usually **DO NOT** want to add settings here at the end, as they would
+-- only apply to the last defined VirtualHost or Component.
+--
+-- Settings for the global section should go higher up, before the first
+-- VirtualHost or Component line, while settings intended for specific hosts
+-- should go under the corresponding VirtualHost or Component line.
+--
+-- For more information see https://prosody.im/doc/configure
--- a/prosodyctl	Mon Dec 12 07:03:31 2022 +0100
+++ b/prosodyctl	Mon Dec 12 07:07:13 2022 +0100
@@ -10,7 +10,6 @@
 -- prosodyctl - command-line controller for Prosody XMPP server
 
 -- Will be modified by configure script if run --
-
 CFG_SOURCEDIR=CFG_SOURCEDIR or os.getenv("PROSODY_SRCDIR");
 CFG_CONFIGDIR=CFG_CONFIGDIR or os.getenv("PROSODY_CFGDIR");
 CFG_PLUGINDIR=CFG_PLUGINDIR or os.getenv("PROSODY_PLUGINDIR");
@@ -20,8 +19,8 @@
 
 local function is_relative(path)
 	local path_sep = package.config:sub(1,1);
-        return ((path_sep == "/" and path:sub(1,1) ~= "/")
-	or (path_sep == "\\" and (path:sub(1,1) ~= "/" and path:sub(2,3) ~= ":\\")))
+	return ((path_sep == "/" and path:sub(1,1) ~= "/")
+		or (path_sep == "\\" and (path:sub(1,1) ~= "/" and path:sub(2,3) ~= ":\\")))
 end
 
 -- Tell Lua where to find our libraries
@@ -50,20 +49,6 @@
 
 -----------
 
-local error_messages = setmetatable({
-		["invalid-username"] = "The given username is invalid in a Jabber ID";
-		["invalid-hostname"] = "The given hostname is invalid";
-		["no-password"] = "No password was supplied";
-		["no-such-user"] = "The given user does not exist on the server";
-		["no-such-host"] = "The given hostname does not exist in the config";
-		["unable-to-save-data"] = "Unable to store, perhaps you don't have permission?";
-		["no-pidfile"] = "There is no 'pidfile' option in the configuration file, see https://prosody.im/doc/prosodyctl#pidfile for help";
-		["invalid-pidfile"] = "The 'pidfile' option in the configuration file is not a string, see https://prosody.im/doc/prosodyctl#pidfile for help";
-		["no-posix"] = "The mod_posix module is not enabled in the Prosody config file, see https://prosody.im/doc/prosodyctl for more info";
-		["no-such-method"] = "This module has no commands";
-		["not-running"] = "Prosody is not running";
-		}, { __index = function (_,k) return "Error: "..(tostring(k):gsub("%-", " "):gsub("^.", string.upper)); end });
-
 local configmanager = require "core.configmanager";
 local modulemanager = require "core.modulemanager"
 local prosodyctl = require "util.prosodyctl"
@@ -73,23 +58,82 @@
 
 -----------------------
 
+local parse_args = require "util.argparse".parse;
+local human_io = require "util.human.io";
+
 local show_message, show_warning = prosodyctl.show_message, prosodyctl.show_warning;
 local show_usage = prosodyctl.show_usage;
-local show_yesno = prosodyctl.show_yesno;
-local show_prompt = prosodyctl.show_prompt;
-local read_password = prosodyctl.read_password;
+local read_password = human_io.read_password;
+local call_luarocks = prosodyctl.call_luarocks;
+local error_messages = prosodyctl.error_messages;
 
 local jid_split = require "util.jid".prepped_split;
 
 local prosodyctl_timeout = (configmanager.get("*", "prosodyctl_timeout") or 5) * 2;
 -----------------------
 local commands = {};
-local command = arg[1];
+local command = table.remove(arg, 1);
+
+local only_help = { short_params = { h = "help"; ["?"] = "help" } }
+
+function commands.install(arg)
+	local opts = parse_args(arg, only_help);
+	if opts.help or not arg[1] then
+		show_usage([[install]], [[Installs a prosody/luarocks plugin]]);
+		return opts.help and 0 or 1;
+	end
+	-- TODO finalize config option name
+	local server = opts.server or configmanager.get("*", "plugin_server");
+	if not (arg[1]:match("^https://") or lfs.attributes(arg[1]) or server) then
+		show_warning("There is no 'plugin_server' option in the configuration file");
+		-- see https://prosody.im/doc/TODO documentation
+		-- #1602
+		return 1;
+	end
+	show_message("Installing %s in %s", arg[1], prosody.paths.installer);
+	local ret = call_luarocks("install", arg[1], server);
+	if ret == 0 and arg[1]:match("^mod_") then
+		prosodyctl.show_module_configuration_help(arg[1]);
+	end
+	return ret;
+end
+
+function commands.remove(arg)
+	local opts = parse_args(arg, only_help);
+	if opts.help or not arg[1] then
+		show_usage([[remove]], [[Removes a module installed in the working directory's plugins folder]]);
+		return opts.help and 0 or 1;
+	end
+	show_message("Removing %s from %s", arg[1], prosody.paths.installer);
+	local ret = call_luarocks("remove", arg[1]);
+	return ret;
+end
+
+function commands.list(arg)
+	local opts = parse_args(arg, only_help);
+	if opts.help then
+		show_usage([[list]], [[Shows installed rocks]]);
+		return 0;
+	end
+	local server = opts.server or configmanager.get("*", "plugin_server");
+	if opts.outdated then
+		-- put this back for luarocks
+		arg[1] = "--outdated";
+
+		if not server then
+			show_warning("There is no 'plugin_server' option in the configuration file, but this is needed for 'list --outdated' to work.");
+			return 1;
+		end
+	end
+	local ret = call_luarocks("list", arg[1], server);
+	return ret;
+end
 
 function commands.adduser(arg)
-	if not arg[1] or arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help or not arg[1] then
 		show_usage([[adduser JID]], [[Create the specified user account in Prosody]]);
-		return 1;
+		return opts.help and 0 or 1;
 	end
 	local user, host = jid_split(arg[1]);
 	if not user and host then
@@ -121,14 +165,15 @@
 
 	if ok then return 0; end
 
-	show_message(msg)
+	show_message(error_messages[msg])
 	return 1;
 end
 
 function commands.passwd(arg)
-	if not arg[1] or arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help or not arg[1] then
 		show_usage([[passwd JID]], [[Set the password for the specified user account in Prosody]]);
-		return 1;
+		return opts.help and 0 or 1;
 	end
 	local user, host = jid_split(arg[1]);
 	if not user and host then
@@ -165,9 +210,10 @@
 end
 
 function commands.deluser(arg)
-	if not arg[1] or arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help or not arg[1] then
 		show_usage([[deluser JID]], [[Permanently remove the specified user account from Prosody]]);
-		return 1;
+		return opts.help and 0 or 1;
 	end
 	local user, host = jid_split(arg[1]);
 	if not user and host then
@@ -199,14 +245,23 @@
 	return 1;
 end
 
+local function has_init_system() --> which
+	lfs = lfs or require"lfs";
+	if lfs.attributes("/etc/systemd") then
+		return "systemd";
+	elseif lfs.attributes("/etc/init.d/prosody") then
+		return "rc.d";
+	end
+end
+
 local function service_command_warning(service_command)
 	if prosody.installed and configmanager.get("*", "prosodyctl_service_warnings") ~= false then
 		show_warning("WARNING: Use of prosodyctl start/stop/restart/reload is not recommended");
 		show_warning("         if Prosody is managed by an init system - use that directly instead.");
-		lfs = lfs or require"lfs";
-		if lfs.attributes("/etc/systemd") then
+		local init = has_init_system()
+		if init == "systemd" then
 			show_warning("         e.g. systemctl %s prosody", service_command);
-		elseif lfs.attributes("/etc/init.d/prosody") then
+		elseif init == "rc.d" then
 			show_warning("         e.g. /etc/init.d/prosody %s", service_command);
 		end
 		show_warning("");
@@ -214,9 +269,10 @@
 end
 
 function commands.start(arg)
-	if arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help then
 		show_usage([[start]], [[Start Prosody]]);
-		return 1;
+		return 0;
 	end
 	service_command_warning("start");
 	local ok, ret = prosodyctl.isrunning();
@@ -238,7 +294,15 @@
 	end
 
 	--luacheck: ignore 411/ret
-	local ok, ret = prosodyctl.start(prosody.paths.source);
+	local lua;
+	do
+		local i = 0;
+		repeat
+			i = i - 1;
+		until arg[i-1] == nil
+		lua = arg[i];
+	end
+	local ok, ret = prosodyctl.start(prosody.paths.source, lua);
 	if ok then
 		local daemonize = configmanager.get("*", "daemonize");
 		if daemonize == nil then
@@ -270,9 +334,10 @@
 end
 
 function commands.status(arg)
-	if arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help then
 		show_usage([[status]], [[Reports the running status of Prosody]]);
-		return 1;
+		return 0;
 	end
 
 	local ok, ret = prosodyctl.isrunning();
@@ -304,9 +369,10 @@
 end
 
 function commands.stop(arg)
-	if arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help then
 		show_usage([[stop]], [[Stop a running Prosody server]]);
-		return 1;
+		return 0;
 	end
 
 	service_command_warning("stop");
@@ -341,7 +407,8 @@
 end
 
 function commands.restart(arg)
-	if arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help then
 		show_usage([[restart]], [[Restart a running Prosody server]]);
 		return 1;
 	end
@@ -353,14 +420,14 @@
 end
 
 function commands.about(arg)
-	if arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help then
 		show_usage([[about]], [[Show information about this Prosody installation]]);
-		return 1;
+		return 0;
 	end
 
 	local pwd = ".";
-	local array = require "util.array";
-	local keys = require "util.iterators".keys;
+	local sorted_pairs = require "util.iterators".sorted_pairs;
 	local hg = require"util.mercurial";
 	local relpath = configmanager.resolve_relative_path;
 
@@ -383,6 +450,13 @@
 				.."\n  ";
 		end)));
 	print("");
+	local have_pposix, pposix = pcall(require, "util.pposix");
+	if have_pposix and pposix.uname then
+		print("# Operating system");
+		local uname, err = pposix.uname();
+		print(uname and uname.sysname .. " " .. uname.release or "Unknown POSIX", err or "");
+		print("");
+	end
 	print("# Lua environment");
 	print("Lua version:             ", _G._VERSION);
 	print("");
@@ -413,36 +487,71 @@
 	print("");
 	print("# Lua module versions");
 	local module_versions, longest_name = {}, 8;
-	local luaevent = dependencies.softreq"luaevent";
-	local lxp = dependencies.softreq"lxp";
+	local library_versions = {};
 	dependencies.softreq"ssl";
 	dependencies.softreq"DBI";
+	dependencies.softreq"readline";
+	local friendly_names = {
+		DBI = "LuaDBI";
+		lfs = "LuaFileSystem";
+		lunbound = "luaunbound";
+		lxp = "LuaExpat";
+		socket = "LuaSocket";
+		ssl = "LuaSec";
+	};
+	local alternate_version_fields = {
+		-- These diverge from the module._VERSION convention
+		readline = "Version";
+	}
+	local lunbound = dependencies.softreq"lunbound";
+	local lxp = dependencies.softreq"lxp";
+	local hashes = dependencies.softreq"util.hashes";
 	for name, module in pairs(package.loaded) do
-		if type(module) == "table" and rawget(module, "_VERSION")
-		and name ~= "_G" and not name:match("%.") then
+		local version_field = alternate_version_fields[name] or "_VERSION";
+		if type(module) == "table" and rawget(module, version_field)
+			and name ~= "_G" and not name:match("%.") then
+			name = friendly_names[name] or name;
 			if #name > longest_name then
 				longest_name = #name;
 			end
-			module_versions[name] = module._VERSION;
+			local mod_version = module[version_field];
+			if tostring(mod_version):sub(1, #name+1) == name .. " " then
+				mod_version = mod_version:sub(#name+2);
+			end
+			module_versions[name] = mod_version;
 		end
 	end
-	if luaevent then
-		module_versions["libevent"] = luaevent.core.libevent_version();
+	if lunbound then
+		if not module_versions["luaunbound"] then
+			module_versions["luaunbound"] = "0.5 (?)";
+		end
+		library_versions["libunbound"] = lunbound._LIBVER;
 	end
 	if lxp then
-		module_versions["libexpat"] = lxp._EXPAT_VERSION;
+		library_versions["libexpat"] = lxp._EXPAT_VERSION;
+	end
+	if hashes then
+		library_versions["libcrypto"] = hashes._LIBCRYPTO_VERSION;
+	end
+	for name, version in sorted_pairs(module_versions) do
+		print(name..":"..string.rep(" ", longest_name-#name), version);
 	end
-	local sorted_keys = array.collect(keys(module_versions)):sort();
-	for _, name in ipairs(sorted_keys) do
-		print(name..":"..string.rep(" ", longest_name-#name), module_versions[name]);
+	print("");
+	print("# library versions");
+	if require "net.server".event_base then
+		library_versions["libevent"] = require"luaevent".core.libevent_version();
+	end
+	for name, version in sorted_pairs(library_versions) do
+		print(name..":"..string.rep(" ", longest_name-#name), version);
 	end
 	print("");
 end
 
 function commands.reload(arg)
-	if arg[1] == "--help" then
+	local opts = parse_args(arg, only_help);
+	if opts.help then
 		show_usage([[reload]], [[Reload Prosody's configuration and re-open log files]]);
-		return 1;
+		return 0;
 	end
 
 	service_command_warning("reload");
@@ -517,810 +626,6 @@
 	return 1;
 end
 
-local openssl;
-
-local cert_commands = {};
-
--- If a file already exists, ask if the user wants to use it or replace it
--- Backups the old file if replaced
-local function use_existing(filename)
-	local attrs = lfs.attributes(filename);
-	if attrs then
-		if show_yesno(filename .. " exists, do you want to replace it? [y/n]") then
-			local backup = filename..".bkp~"..os.date("%FT%T", attrs.change);
-			os.rename(filename, backup);
-			show_message(filename.." backed up to "..backup);
-		else
-			-- Use the existing file
-			return true;
-		end
-	end
-end
-
-local have_pposix, pposix = pcall(require, "util.pposix");
-local cert_basedir = prosody.paths.data == "." and "./certs" or prosody.paths.data;
-if have_pposix and pposix.getuid() == 0 then
-	-- FIXME should be enough to check if this directory is writable
-	local cert_dir = configmanager.get("*", "certificates") or "certs";
-	cert_basedir = configmanager.resolve_relative_path(prosody.paths.config, cert_dir);
-end
-
-function cert_commands.config(arg)
-	if #arg >= 1 and arg[1] ~= "--help" then
-		local conf_filename = cert_basedir .. "/" .. arg[1] .. ".cnf";
-		if use_existing(conf_filename) then
-			return nil, conf_filename;
-		end
-		local distinguished_name;
-		if arg[#arg]:find("^/") then
-			distinguished_name = table.remove(arg);
-		end
-		local conf = openssl.config.new();
-		conf:from_prosody(prosody.hosts, configmanager, arg);
-		if distinguished_name then
-			local dn = {};
-			for k, v in distinguished_name:gmatch("/([^=/]+)=([^/]+)") do
-				table.insert(dn, k);
-				dn[k] = v;
-			end
-			conf.distinguished_name = dn;
-		else
-			show_message("Please provide details to include in the certificate config file.");
-			show_message("Leave the field empty to use the default value or '.' to exclude the field.")
-			for _, k in ipairs(openssl._DN_order) do
-				local v = conf.distinguished_name[k];
-				if v then
-					local nv = nil;
-					if k == "commonName" then
-						v = arg[1]
-					elseif k == "emailAddress" then
-						v = "xmpp@" .. arg[1];
-					elseif k == "countryName" then
-						local tld = arg[1]:match"%.([a-z]+)$";
-						if tld and #tld == 2 and tld ~= "uk" then
-							v = tld:upper();
-						end
-					end
-					nv = show_prompt(("%s (%s):"):format(k, nv or v));
-					nv = (not nv or nv == "") and v or nv;
-					if nv:find"[\192-\252][\128-\191]+" then
-						conf.req.string_mask = "utf8only"
-					end
-					conf.distinguished_name[k] = nv ~= "." and nv or nil;
-				end
-			end
-		end
-		local conf_file, err = io.open(conf_filename, "w");
-		if not conf_file then
-			show_warning("Could not open OpenSSL config file for writing");
-			show_warning(err);
-			os.exit(1);
-		end
-		conf_file:write(conf:serialize());
-		conf_file:close();
-		print("");
-		show_message("Config written to " .. conf_filename);
-		return nil, conf_filename;
-	else
-		show_usage("cert config HOSTNAME [HOSTNAME+]", "Builds a certificate config file covering the supplied hostname(s)")
-	end
-end
-
-function cert_commands.key(arg)
-	if #arg >= 1 and arg[1] ~= "--help" then
-		local key_filename = cert_basedir .. "/" .. arg[1] .. ".key";
-		if use_existing(key_filename) then
-			return nil, key_filename;
-		end
-		os.remove(key_filename); -- This file, if it exists is unlikely to have write permissions
-		local key_size = tonumber(arg[2] or show_prompt("Choose key size (2048):") or 2048);
-		local old_umask = pposix.umask("0377");
-		if openssl.genrsa{out=key_filename, key_size} then
-			os.execute(("chmod 400 '%s'"):format(key_filename));
-			show_message("Key written to ".. key_filename);
-			pposix.umask(old_umask);
-			return nil, key_filename;
-		end
-		show_message("There was a problem, see OpenSSL output");
-	else
-		show_usage("cert key HOSTNAME <bits>", "Generates a RSA key named HOSTNAME.key\n "
-		.."Prompts for a key size if none given")
-	end
-end
-
-function cert_commands.request(arg)
-	if #arg >= 1 and arg[1] ~= "--help" then
-		local req_filename = cert_basedir .. "/" .. arg[1] .. ".req";
-		if use_existing(req_filename) then
-			return nil, req_filename;
-		end
-		local _, key_filename = cert_commands.key({arg[1]});
-		local _, conf_filename = cert_commands.config(arg);
-		if openssl.req{new=true, key=key_filename, utf8=true, sha256=true, config=conf_filename, out=req_filename} then
-			show_message("Certificate request written to ".. req_filename);
-		else
-			show_message("There was a problem, see OpenSSL output");
-		end
-	else
-		show_usage("cert request HOSTNAME [HOSTNAME+]", "Generates a certificate request for the supplied hostname(s)")
-	end
-end
-
-function cert_commands.generate(arg)
-	if #arg >= 1 and arg[1] ~= "--help" then
-		local cert_filename = cert_basedir .. "/" .. arg[1] .. ".crt";
-		if use_existing(cert_filename) then
-			return nil, cert_filename;
-		end
-		local _, key_filename = cert_commands.key({arg[1]});
-		local _, conf_filename = cert_commands.config(arg);
-		if key_filename and conf_filename and cert_filename
-			and openssl.req{new=true, x509=true, nodes=true, key=key_filename,
-				days=365, sha256=true, utf8=true, config=conf_filename, out=cert_filename} then
-			show_message("Certificate written to ".. cert_filename);
-			print();
-		else
-			show_message("There was a problem, see OpenSSL output");
-		end
-	else
-		show_usage("cert generate HOSTNAME [HOSTNAME+]", "Generates a self-signed certificate for the current hostname(s)")
-	end
-end
-
-local function sh_esc(s)
-	return "'" .. s:gsub("'", "'\\''") .. "'";
-end
-
-local function copy(from, to, umask, owner, group)
-	local old_umask = umask and pposix.umask(umask);
-	local attrs = lfs.attributes(to);
-	if attrs then -- Move old file out of the way
-		local backup = to..".bkp~"..os.date("%FT%T", attrs.change);
-		os.rename(to, backup);
-	end
-	-- FIXME friendlier error handling, maybe move above backup back?
-	local input = assert(io.open(from));
-	local output = assert(io.open(to, "w"));
-	local data = input:read(2^11);
-	while data and output:write(data) do
-		data = input:read(2^11);
-	end
-	assert(input:close());
-	assert(output:close());
-	if not prosody.installed then
-		-- FIXME this is possibly specific to GNU chown
-		os.execute(("chown -c --reference=%s %s"):format(sh_esc(cert_basedir), sh_esc(to)));
-	elseif owner and group then
-		local ok = os.execute(("chown %s:%s %s"):format(sh_esc(owner), sh_esc(group), sh_esc(to)));
-		assert(ok == true or ok == 0, "Failed to change ownership of "..to);
-	end
-	if old_umask then pposix.umask(old_umask); end
-	return true;
-end
-
-function cert_commands.import(arg)
-	local hostnames = {};
-	-- Move hostname arguments out of arg, the rest should be a list of paths
-	while arg[1] and prosody.hosts[ arg[1] ] do
-		table.insert(hostnames, table.remove(arg, 1));
-	end
-	if hostnames[1] == nil then
-		local domains = os.getenv"RENEWED_DOMAINS"; -- Set if invoked via certbot
-		if domains then
-			for host in domains:gmatch("%S+") do
-				table.insert(hostnames, host);
-			end
-		else
-			for host in pairs(prosody.hosts) do
-				if host ~= "*" and configmanager.get(host, "enabled") ~= false then
-					table.insert(hostnames, host);
-				end
-			end
-		end
-	end
-	if not arg[1] or arg[1] == "--help" then -- Probably forgot the path
-		show_usage("cert import [HOSTNAME+] /path/to/certs [/other/paths/]+",
-			"Copies certificates to "..cert_basedir);
-		return 1;
-	end
-	local owner, group;
-	if pposix.getuid() == 0 then -- We need root to change ownership
-		owner = configmanager.get("*", "prosody_user") or "prosody";
-		group = configmanager.get("*", "prosody_group") or owner;
-	end
-	local cm = require "core.certmanager";
-	local imported = {};
-	for _, host in ipairs(hostnames) do
-		for _, dir in ipairs(arg) do
-			local paths = cm.find_cert(dir, host);
-			if paths then
-				copy(paths.certificate, cert_basedir .. "/" .. host .. ".crt", nil, owner, group);
-				copy(paths.key, cert_basedir .. "/" .. host .. ".key", "0377", owner, group);
-				table.insert(imported, host);
-			else
-				-- TODO Say where we looked
-				show_warning("No certificate for host "..host.." found :(");
-			end
-			-- TODO Additional checks
-			-- Certificate names matches the hostname
-			-- Private key matches public key in certificate
-		end
-	end
-	if imported[1] then
-		show_message("Imported certificate and key for hosts "..table.concat(imported, ", "));
-		local ok, err = prosodyctl.reload();
-		if not ok and err ~= "not-running" then
-			show_message(error_messages[err]);
-		end
-	else
-		show_warning("No certificates imported :(");
-		return 1;
-	end
-end
-
-function commands.cert(arg)
-	if #arg >= 1 and arg[1] ~= "--help" then
-		openssl = require "util.openssl";
-		lfs = require "lfs";
-		local cert_dir_attrs = lfs.attributes(cert_basedir);
-		if not cert_dir_attrs then
-			show_warning("The directory "..cert_basedir.." does not exist");
-			return 1; -- TODO Should we create it?
-		end
-		local uid = pposix.getuid();
-		if uid ~= 0 and uid ~= cert_dir_attrs.uid then
-			show_warning("The directory "..cert_basedir.." is not owned by the current user, won't be able to write files to it");
-			return 1;
-		elseif not cert_dir_attrs.permissions then -- COMPAT with LuaFilesystem < 1.6.2 (hey CentOS!)
-			show_message("Unable to check permissions on "..cert_basedir.." (LuaFilesystem 1.6.2+ required)");
-			show_message("Please confirm that Prosody (and only Prosody) can write to this directory)");
-		elseif cert_dir_attrs.permissions:match("^%.w..%-..%-.$") then
-			show_warning("The directory "..cert_basedir.." not only writable by its owner");
-			return 1;
-		end
-		local subcmd = table.remove(arg, 1);
-		if type(cert_commands[subcmd]) == "function" then
-			if subcmd ~= "import" then -- hostnames are optional for import
-				if not arg[1] then
-					show_message"You need to supply at least one hostname"
-					arg = { "--help" };
-				end
-				if arg[1] ~= "--help" and not prosody.hosts[arg[1]] then
-					show_message(error_messages["no-such-host"]);
-					return 1;
-				end
-			end
-			return cert_commands[subcmd](arg);
-		elseif subcmd == "check" then
-			return commands.check({"certs"});
-		end
-	end
-	show_usage("cert config|request|generate|key|import", "Helpers for generating X.509 certificates and keys.")
-	for _, cmd in pairs(cert_commands) do
-		print()
-		cmd{ "--help" }
-	end
-end
-
-function commands.check(arg)
-	if arg[1] == "--help" then
-		show_usage([[check]], [[Perform basic checks on your Prosody installation]]);
-		return 1;
-	end
-	local what = table.remove(arg, 1);
-	local set = require "util.set";
-	local it = require "util.iterators";
-	local ok = true;
-	local function disabled_hosts(host, conf) return host ~= "*" and conf.enabled ~= false; end
-	local function enabled_hosts() return it.filter(disabled_hosts, pairs(configmanager.getconfig())); end
-	if not (what == nil or what == "disabled" or what == "config" or what == "dns" or what == "certs") then
-		show_warning("Don't know how to check '%s'. Try one of 'config', 'dns', 'certs' or 'disabled'.", what);
-		return 1;
-	end
-	if not what or what == "disabled" then
-		local disabled_hosts_set = set.new();
-		for host, host_options in it.filter("*", pairs(configmanager.getconfig())) do
-			if host_options.enabled == false then
-				disabled_hosts_set:add(host);
-			end
-		end
-		if not disabled_hosts_set:empty() then
-			local msg = "Checks will be skipped for these disabled hosts: %s";
-			if what then msg = "These hosts are disabled: %s"; end
-			show_warning(msg, tostring(disabled_hosts_set));
-			if what then return 0; end
-			print""
-		end
-	end
-	if not what or what == "config" then
-		print("Checking config...");
-		local deprecated = set.new({
-			"bosh_ports", "disallow_s2s", "no_daemonize", "anonymous_login", "require_encryption",
-			"vcard_compatibility",
-		});
-		local known_global_options = set.new({
-			"pidfile", "log", "plugin_paths", "prosody_user", "prosody_group", "daemonize",
-			"umask", "prosodyctl_timeout", "use_ipv6", "use_libevent", "network_settings",
-			"network_backend", "http_default_host", "gc", "limits",
-			"statistics_interval", "statistics", "statistics_config",
-		});
-		local config = configmanager.getconfig();
-		-- Check that we have any global options (caused by putting a host at the top)
-		if it.count(it.filter("log", pairs(config["*"]))) == 0 then
-			ok = false;
-			print("");
-			print("    No global options defined. Perhaps you have put a host definition at the top")
-			print("    of the config file? They should be at the bottom, see https://prosody.im/doc/configure#overview");
-		end
-		if it.count(enabled_hosts()) == 0 then
-			ok = false;
-			print("");
-			if it.count(it.filter("*", pairs(config))) == 0 then
-				print("    No hosts are defined, please add at least one VirtualHost section")
-			elseif config["*"]["enabled"] == false then
-				print("    No hosts are enabled. Remove enabled = false from the global section or put enabled = true under at least one VirtualHost section")
-			else
-				print("    All hosts are disabled. Remove enabled = false from at least one VirtualHost section")
-			end
-		end
-		if not config["*"].modules_enabled then
-			print("    No global modules_enabled is set?");
-			local suggested_global_modules;
-			for host, options in enabled_hosts() do --luacheck: ignore 213/host
-				if not options.component_module and options.modules_enabled then
-					suggested_global_modules = set.intersection(suggested_global_modules or set.new(options.modules_enabled), set.new(options.modules_enabled));
-				end
-			end
-			if suggested_global_modules and not suggested_global_modules:empty() then
-				print("    Consider moving these modules into modules_enabled in the global section:")
-				print("    "..tostring(suggested_global_modules / function (x) return ("%q"):format(x) end));
-			end
-			print();
-		end
-
-		do -- Check for modules enabled both normally and as components
-			local modules = set.new(config["*"]["modules_enabled"]);
-			for host, options in enabled_hosts() do
-				local component_module = options.component_module;
-				if component_module and modules:contains(component_module) then
-					print(("    mod_%s is enabled both in modules_enabled and as Component %q %q"):format(component_module, host, component_module));
-					print("    This means the service is enabled on all VirtualHosts as well as the Component.");
-					print("    Are you sure this what you want? It may cause unexpected behaviour.");
-				end
-			end
-		end
-
-		-- Check for global options under hosts
-		local global_options = set.new(it.to_array(it.keys(config["*"])));
-		local deprecated_global_options = set.intersection(global_options, deprecated);
-		if not deprecated_global_options:empty() then
-			print("");
-			print("    You have some deprecated options in the global section:");
-			print("    "..tostring(deprecated_global_options))
-			ok = false;
-		end
-		for host, options in it.filter(function (h) return h ~= "*" end, pairs(configmanager.getconfig())) do
-			local host_options = set.new(it.to_array(it.keys(options)));
-			local misplaced_options = set.intersection(host_options, known_global_options);
-			for name in pairs(options) do
-				if name:match("^interfaces?")
-				or name:match("_ports?$") or name:match("_interfaces?$")
-				or (name:match("_ssl$") and not name:match("^[cs]2s_ssl$")) then
-					misplaced_options:add(name);
-				end
-			end
-			if not misplaced_options:empty() then
-				ok = false;
-				print("");
-				local n = it.count(misplaced_options);
-				print("    You have "..n.." option"..(n>1 and "s " or " ").."set under "..host.." that should be");
-				print("    in the global section of the config file, above any VirtualHost or Component definitions,")
-				print("    see https://prosody.im/doc/configure#overview for more information.")
-				print("");
-				print("    You need to move the following option"..(n>1 and "s" or "")..": "..table.concat(it.to_array(misplaced_options), ", "));
-			end
-		end
-		for host, options in enabled_hosts() do
-			local host_options = set.new(it.to_array(it.keys(options)));
-			local subdomain = host:match("^[^.]+");
-			if not(host_options:contains("component_module")) and (subdomain == "jabber" or subdomain == "xmpp"
-			   or subdomain == "chat" or subdomain == "im") then
-				print("");
-				print("    Suggestion: If "..host.. " is a new host with no real users yet, consider renaming it now to");
-				print("     "..host:gsub("^[^.]+%.", "")..". You can use SRV records to redirect XMPP clients and servers to "..host..".");
-				print("     For more information see: https://prosody.im/doc/dns");
-			end
-		end
-		local all_modules = set.new(config["*"].modules_enabled);
-		local all_options = set.new(it.to_array(it.keys(config["*"])));
-		for host in enabled_hosts() do
-			all_options:include(set.new(it.to_array(it.keys(config[host]))));
-			all_modules:include(set.new(config[host].modules_enabled));
-		end
-		for mod in all_modules do
-			if mod:match("^mod_") then
-				print("");
-				print("    Modules in modules_enabled should not have the 'mod_' prefix included.");
-				print("    Change '"..mod.."' to '"..mod:match("^mod_(.*)").."'.");
-			elseif mod:match("^auth_") then
-				print("");
-				print("    Authentication modules should not be added to modules_enabled,");
-				print("    but be specified in the 'authentication' option.");
-				print("    Remove '"..mod.."' from modules_enabled and instead add");
-				print("        authentication = '"..mod:match("^auth_(.*)").."'");
-				print("    For more information see https://prosody.im/doc/authentication");
-			elseif mod:match("^storage_") then
-				print("");
-				print("    storage modules should not be added to modules_enabled,");
-				print("    but be specified in the 'storage' option.");
-				print("    Remove '"..mod.."' from modules_enabled and instead add");
-				print("        storage = '"..mod:match("^storage_(.*)").."'");
-				print("    For more information see https://prosody.im/doc/storage");
-			end
-		end
-		if all_modules:contains("vcard") and all_modules:contains("vcard_legacy") then
-			print("");
-			print("    Both mod_vcard_legacy and mod_vcard are enabled but they conflict");
-			print("    with each other. Remove one.");
-		end
-		if all_modules:contains("pep") and all_modules:contains("pep_simple") then
-			print("");
-			print("    Both mod_pep_simple and mod_pep are enabled but they conflict");
-			print("    with each other. Remove one.");
-		end
-		for host, host_config in pairs(config) do --luacheck: ignore 213/host
-			if type(rawget(host_config, "storage")) == "string" and rawget(host_config, "default_storage") then
-				print("");
-				print("    The 'default_storage' option is not needed if 'storage' is set to a string.");
-				break;
-			end
-		end
-		local require_encryption = set.intersection(all_options, set.new({
-			"require_encryption", "c2s_require_encryption", "s2s_require_encryption"
-		})):empty();
-		local ssl = dependencies.softreq"ssl";
-		if not ssl then
-			if not require_encryption then
-				print("");
-				print("    You require encryption but LuaSec is not available.");
-				print("    Connections will fail.");
-				ok = false;
-			end
-		elseif not ssl.loadcertificate then
-			if all_options:contains("s2s_secure_auth") then
-				print("");
-				print("    You have set s2s_secure_auth but your version of LuaSec does ");
-				print("    not support certificate validation, so all s2s connections will");
-				print("    fail.");
-				ok = false;
-			elseif all_options:contains("s2s_secure_domains") then
-				local secure_domains = set.new();
-				for host in enabled_hosts() do
-					if config[host].s2s_secure_auth == true then
-						secure_domains:add("*");
-					else
-						secure_domains:include(set.new(config[host].s2s_secure_domains));
-					end
-				end
-				if not secure_domains:empty() then
-					print("");
-					print("    You have set s2s_secure_domains but your version of LuaSec does ");
-					print("    not support certificate validation, so s2s connections to/from ");
-					print("    these domains will fail.");
-					ok = false;
-				end
-			end
-		elseif require_encryption and not all_modules:contains("tls") then
-			print("");
-			print("    You require encryption but mod_tls is not enabled.");
-			print("    Connections will fail.");
-			ok = false;
-		end
-
-		print("Done.\n");
-	end
-	if not what or what == "dns" then
-		local dns = require "net.dns";
-		local idna = require "util.encodings".idna;
-		local ip = require "util.ip";
-		local c2s_ports = set.new(configmanager.get("*", "c2s_ports") or {5222});
-		local s2s_ports = set.new(configmanager.get("*", "s2s_ports") or {5269});
-
-		local c2s_srv_required, s2s_srv_required;
-		if not c2s_ports:contains(5222) then
-			c2s_srv_required = true;
-		end
-		if not s2s_ports:contains(5269) then
-			s2s_srv_required = true;
-		end
-
-		local problem_hosts = set.new();
-
-		local external_addresses, internal_addresses = set.new(), set.new();
-
-		local fqdn = socket.dns.tohostname(socket.dns.gethostname());
-		if fqdn then
-			do
-				local res = dns.lookup(idna.to_ascii(fqdn), "A");
-				if res then
-					for _, record in ipairs(res) do
-						external_addresses:add(record.a);
-					end
-				end
-			end
-			do
-				local res = dns.lookup(idna.to_ascii(fqdn), "AAAA");
-				if res then
-					for _, record in ipairs(res) do
-						external_addresses:add(record.aaaa);
-					end
-				end
-			end
-		end
-
-		local local_addresses = require"util.net".local_addresses() or {};
-
-		for addr in it.values(local_addresses) do
-			if not ip.new_ip(addr).private then
-				external_addresses:add(addr);
-			else
-				internal_addresses:add(addr);
-			end
-		end
-
-		if external_addresses:empty() then
-			print("");
-			print("   Failed to determine the external addresses of this server. Checks may be inaccurate.");
-			c2s_srv_required, s2s_srv_required = true, true;
-		end
-
-		local v6_supported = not not socket.tcp6;
-
-		for jid, host_options in enabled_hosts() do
-			local all_targets_ok, some_targets_ok = true, false;
-			local node, host = jid_split(jid);
-
-			local modules, component_module = modulemanager.get_modules_for_host(host);
-			if component_module then
-				modules:add(component_module);
-			end
-
-			local is_component = not not host_options.component_module;
-			print("Checking DNS for "..(is_component and "component" or "host").." "..jid.."...");
-			if node then
-				print("Only the domain part ("..host..") is used in DNS.")
-			end
-			local target_hosts = set.new();
-			if modules:contains("c2s") then
-				local res = dns.lookup("_xmpp-client._tcp."..idna.to_ascii(host)..".", "SRV");
-				if res then
-					for _, record in ipairs(res) do
-						target_hosts:add(record.srv.target);
-						if not c2s_ports:contains(record.srv.port) then
-							print("    SRV target "..record.srv.target.." contains unknown client port: "..record.srv.port);
-						end
-					end
-				else
-					if c2s_srv_required then
-						print("    No _xmpp-client SRV record found for "..host..", but it looks like you need one.");
-						all_targets_ok = false;
-					else
-						target_hosts:add(host);
-					end
-				end
-			end
-			if modules:contains("s2s") then
-				local res = dns.lookup("_xmpp-server._tcp."..idna.to_ascii(host)..".", "SRV");
-				if res then
-					for _, record in ipairs(res) do
-						target_hosts:add(record.srv.target);
-						if not s2s_ports:contains(record.srv.port) then
-							print("    SRV target "..record.srv.target.." contains unknown server port: "..record.srv.port);
-						end
-					end
-				else
-					if s2s_srv_required then
-						print("    No _xmpp-server SRV record found for "..host..", but it looks like you need one.");
-						all_targets_ok = false;
-					else
-						target_hosts:add(host);
-					end
-				end
-			end
-			if target_hosts:empty() then
-				target_hosts:add(host);
-			end
-
-			if target_hosts:contains("localhost") then
-				print("    Target 'localhost' cannot be accessed from other servers");
-				target_hosts:remove("localhost");
-			end
-
-			if modules:contains("proxy65") then
-				local proxy65_target = configmanager.get(host, "proxy65_address") or host;
-				if type(proxy65_target) == "string" then
-					local A, AAAA = dns.lookup(idna.to_ascii(proxy65_target), "A"), dns.lookup(idna.to_ascii(proxy65_target), "AAAA");
-					local prob = {};
-					if not A then
-						table.insert(prob, "A");
-					end
-					if v6_supported and not AAAA then
-						table.insert(prob, "AAAA");
-					end
-					if #prob > 0 then
-						print("    File transfer proxy "..proxy65_target.." has no "..table.concat(prob, "/")
-						.." record. Create one or set 'proxy65_address' to the correct host/IP.");
-					end
-				else
-					print("    proxy65_address for "..host.." should be set to a string, unable to perform DNS check");
-				end
-			end
-
-			for target_host in target_hosts do
-				local host_ok_v4, host_ok_v6;
-				do
-					local res = dns.lookup(idna.to_ascii(target_host), "A");
-					if res then
-						for _, record in ipairs(res) do
-							if external_addresses:contains(record.a) then
-								some_targets_ok = true;
-								host_ok_v4 = true;
-							elseif internal_addresses:contains(record.a) then
-								host_ok_v4 = true;
-								some_targets_ok = true;
-								print("    "..target_host.." A record points to internal address, external connections might fail");
-							else
-								print("    "..target_host.." A record points to unknown address "..record.a);
-								all_targets_ok = false;
-							end
-						end
-					end
-				end
-				do
-					local res = dns.lookup(idna.to_ascii(target_host), "AAAA");
-					if res then
-						for _, record in ipairs(res) do
-							if external_addresses:contains(record.aaaa) then
-								some_targets_ok = true;
-								host_ok_v6 = true;
-							elseif internal_addresses:contains(record.aaaa) then
-								host_ok_v6 = true;
-								some_targets_ok = true;
-								print("    "..target_host.." AAAA record points to internal address, external connections might fail");
-							else
-								print("    "..target_host.." AAAA record points to unknown address "..record.aaaa);
-								all_targets_ok = false;
-							end
-						end
-					end
-				end
-
-				local bad_protos = {}
-				if not host_ok_v4 then
-					table.insert(bad_protos, "IPv4");
-				end
-				if not host_ok_v6 then
-					table.insert(bad_protos, "IPv6");
-				end
-				if #bad_protos > 0 then
-					print("    Host "..target_host.." does not seem to resolve to this server ("..table.concat(bad_protos, "/")..")");
-				end
-				if host_ok_v6 and not v6_supported then
-					print("    Host "..target_host.." has AAAA records, but your version of LuaSocket does not support IPv6.");
-					print("      Please see https://prosody.im/doc/ipv6 for more information.");
-				end
-			end
-			if not all_targets_ok then
-				print("    "..(some_targets_ok and "Only some" or "No").." targets for "..host.." appear to resolve to this server.");
-				if is_component then
-					print("    DNS records are necessary if you want users on other servers to access this component.");
-				end
-				problem_hosts:add(host);
-			end
-			print("");
-		end
-		if not problem_hosts:empty() then
-			print("");
-			print("For more information about DNS configuration please see https://prosody.im/doc/dns");
-			print("");
-			ok = false;
-		end
-	end
-	if not what or what == "certs" then
-		local cert_ok;
-		print"Checking certificates..."
-		local x509_verify_identity = require"util.x509".verify_identity;
-		local create_context = require "core.certmanager".create_context;
-		local ssl = dependencies.softreq"ssl";
-		-- local datetime_parse = require"util.datetime".parse_x509;
-		local load_cert = ssl and ssl.loadcertificate;
-		-- or ssl.cert_from_pem
-		if not ssl then
-			print("LuaSec not available, can't perform certificate checks")
-			if what == "certs" then cert_ok = false end
-		elseif not load_cert then
-			print("This version of LuaSec (" .. ssl._VERSION .. ") does not support certificate checking");
-			cert_ok = false
-		else
-			local function skip_bare_jid_hosts(host)
-				if jid_split(host) then
-					-- See issue #779
-					return false;
-				end
-				return true;
-			end
-			for host in it.filter(skip_bare_jid_hosts, enabled_hosts()) do
-				print("Checking certificate for "..host);
-				-- First, let's find out what certificate this host uses.
-				local host_ssl_config = configmanager.rawget(host, "ssl")
-					or configmanager.rawget(host:match("%.(.*)"), "ssl");
-				local global_ssl_config = configmanager.rawget("*", "ssl");
-				local ok, err, ssl_config = create_context(host, "server", host_ssl_config, global_ssl_config);
-				if not ok then
-					print("  Error: "..err);
-					cert_ok = false
-				elseif not ssl_config.certificate then
-					print("  No 'certificate' found for "..host)
-					cert_ok = false
-				elseif not ssl_config.key then
-					print("  No 'key' found for "..host)
-					cert_ok = false
-				else
-					local key, err = io.open(ssl_config.key); -- Permissions check only
-					if not key then
-						print("    Could not open "..ssl_config.key..": "..err);
-						cert_ok = false
-					else
-						key:close();
-					end
-					local cert_fh, err = io.open(ssl_config.certificate); -- Load the file.
-					if not cert_fh then
-						print("    Could not open "..ssl_config.certificate..": "..err);
-						cert_ok = false
-					else
-						print("  Certificate: "..ssl_config.certificate)
-						local cert = load_cert(cert_fh:read"*a"); cert_fh:close();
-						if not cert:validat(os.time()) then
-							print("    Certificate has expired.")
-							cert_ok = false
-						elseif not cert:validat(os.time() + 86400) then
-							print("    Certificate expires within one day.")
-							cert_ok = false
-						elseif not cert:validat(os.time() + 86400*7) then
-							print("    Certificate expires within one week.")
-						elseif not cert:validat(os.time() + 86400*31) then
-							print("    Certificate expires within one month.")
-						end
-						if configmanager.get(host, "component_module") == nil
-							and not x509_verify_identity(host, "_xmpp-client", cert) then
-							print("    Not valid for client connections to "..host..".")
-							cert_ok = false
-						end
-						if (not (configmanager.get(host, "anonymous_login")
-							or configmanager.get(host, "authentication") == "anonymous"))
-							and not x509_verify_identity(host, "_xmpp-server", cert) then
-							print("    Not valid for server-to-server connections to "..host..".")
-							cert_ok = false
-						end
-					end
-				end
-			end
-		end
-		if cert_ok == false then
-			print("")
-			print("For more information about certificates please see https://prosody.im/doc/certificates");
-			ok = false
-		end
-		print("")
-	end
-	if not ok then
-		print("Problems found, see above.");
-	else
-		print("All checks passed, congratulations!");
-	end
-	return ok and 0 or 2;
-end
-
 ---------------------
 
 local async = require "util.async";
@@ -1344,8 +649,6 @@
 			end
 		end
 
-		table.remove(arg, 1);
-
 		local module = modulemanager.get_module("*", module_name);
 		if not module then
 			show_message("Failed to load module '"..module_name.."': Unknown error");
@@ -1371,36 +674,72 @@
 		end
 	end
 
+	if command and not commands[command] then
+		local ok, command_module = pcall(require, "util.prosodyctl."..command);
+		if ok and command_module[command] then
+			commands[command] = command_module[command];
+		end
+	end
+
 	if not commands[command] then -- Show help for all commands
 		function show_usage(usage, desc)
-			print(" "..usage);
-			print("    "..desc);
+			print(string.format(" %-11s    %s", usage, desc));
 		end
 
 		print("prosodyctl - Manage a Prosody server");
 		print("");
 		print("Usage: "..arg[0].." COMMAND [OPTIONS]");
 		print("");
-		print("Where COMMAND may be one of:\n");
+		print("Where COMMAND may be one of:");
 
-		local hidden_commands = require "util.set".new{ "register", "unregister", "addplugin" };
-		local commands_order = { "adduser", "passwd", "deluser", "start", "stop", "restart", "reload", "about" };
+		local hidden_commands = require "util.set".new{ "register", "unregister" };
+		local commands_order = {
+			"Process management:",
+				"start"; "stop"; "restart"; "reload"; "status";
+				"shell",
+			"User management:",
+				"adduser"; "passwd"; "deluser";
+			"Plugin management:",
+				"install"; "remove"; "list";
+			"Informative:",
+				"about",
+				"check",
+			"Other:",
+				"cert",
+		};
+		-- These live in util.prosodyctl.$command so we have their short help here.
+		local external_commands = {
+			cert = "Certificate management commands",
+			check = "Perform basic checks on your Prosody installation",
+			shell = "Interact with a running Prosody",
+		}
 
 		local done = {};
 
+		if prosody.installed and has_init_system() then
+			-- Hide start, stop, restart
+			done[table.remove(commands_order, 2)] = true;
+			done[table.remove(commands_order, 2)] = true;
+			done[table.remove(commands_order, 2)] = true;
+		end
+
 		for _, command_name in ipairs(commands_order) do
 			local command_func = commands[command_name];
 			if command_func then
 				command_func{ "--help" };
+				done[command_name] = true;
+			elseif external_commands[command_name] then
+				show_usage(command_name, external_commands[command_name]);
+				done[command_name] = true;
+			else
 				print""
-				done[command_name] = true;
+				print(command_name);
 			end
 		end
 
 		for command_name, command_func in pairs(commands) do
 			if not done[command_name] and not hidden_commands:contains(command_name) then
 				command_func{ "--help" };
-				print""
 				done[command_name] = true;
 			end
 		end
@@ -1409,7 +748,7 @@
 		os.exit(0);
 	end
 
-	os.exit(commands[command]({ select(2, unpack(arg)) }));
+	os.exit(commands[command](arg));
 end, watchers);
 
 command_runner:run(true);
--- a/spec/core_configmanager_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/core_configmanager_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,7 +9,9 @@
 
 			configmanager.set("*", "testkey1", 321);
 			assert.are.equal(321, configmanager.get("*", "testkey1"), "Retrieving a set global key");
-			assert.are.equal(321, configmanager.get("example.com", "testkey1"), "Retrieving a set key of undefined host, of which only a globally set one exists");
+			assert.are.equal(321, configmanager.get("example.com", "testkey1"),
+				"Retrieving a set key of undefined host, of which only a globally set one exists"
+			);
 
 			configmanager.set("example.com", ""); -- Creates example.com host in config
 			assert.are.equal(321, configmanager.get("example.com", "testkey1"), "Retrieving a set key, of which only a globally set one exists");
--- a/spec/core_storagemanager_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/core_storagemanager_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,4 +1,4 @@
-local unpack = table.unpack or unpack;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113
 local server = require "net.server_select";
 package.loaded["net.server"] = server;
 
@@ -90,6 +90,112 @@
 				end);
 			end);
 
+			describe("map stores", function ()
+				-- These tests rely on being executed in order, disable any order
+				-- randomization for this block
+				randomize(false);
+
+				local store, kv_store;
+				it("may be opened", function ()
+					store = assert(sm.open(test_host, "test-map", "map"));
+				end);
+
+				it("may be opened as a keyval store", function ()
+					kv_store = assert(sm.open(test_host, "test-map", "keyval"));
+				end);
+
+				it("may set a specific key for a user", function ()
+					assert(store:set("user9999", "foo", "bar"));
+					assert.same(kv_store:get("user9999"), { foo = "bar" });
+				end);
+
+				it("may get a specific key for a user", function ()
+					assert.equal("bar", store:get("user9999", "foo"));
+				end);
+
+				it("may find all users with a specific key", function ()
+					assert.is_function(store.get_all);
+					assert(store:set("user9999b", "bar", "bar"));
+					assert(store:set("user9999c", "foo", "blah"));
+					local ret, err = store:get_all("foo");
+					assert.is_nil(err);
+					assert.same({ user9999 = "bar", user9999c = "blah" }, ret);
+				end);
+
+				it("rejects empty or non-string keys to get_all", function ()
+					assert.is_function(store.get_all);
+					do
+						local ret, err = store:get_all("");
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+					do
+						local ret, err = store:get_all(true);
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+				end);
+
+				it("rejects empty or non-string keys to delete_all", function ()
+					assert.is_function(store.delete_all);
+					do
+						local ret, err = store:delete_all("");
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+					do
+						local ret, err = store:delete_all(true);
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+				end);
+
+				it("may delete all instances of a specific key", function ()
+					assert.is_function(store.delete_all);
+					assert(store:set("user9999b", "foo", "hello"));
+
+					assert(store:delete_all("bar"));
+					-- Ensure key was deleted
+					do
+						local ret, err = store:get("user9999b", "bar");
+						assert.is_nil(ret);
+						assert.is_nil(err);
+					end
+					-- Ensure other users/keys are intact
+					do
+						local ret, err = store:get("user9999", "foo");
+						assert.equal("bar", ret);
+						assert.is_nil(err);
+					end
+					do
+						local ret, err = store:get("user9999b", "foo");
+						assert.equal("hello", ret);
+						assert.is_nil(err);
+					end
+					do
+						local ret, err = store:get("user9999c", "foo");
+						assert.equal("blah", ret);
+						assert.is_nil(err);
+					end
+				end);
+
+				it("may remove data for a specific key for a user", function ()
+					assert(store:set("user9999", "foo", nil));
+					do
+						local ret, err = store:get("user9999", "foo");
+						assert.is_nil(ret);
+						assert.is_nil(err);
+					end
+
+					assert(store:set("user9999b", "foo", nil));
+					do
+						local ret, err = store:get("user9999b", "foo");
+						assert.is_nil(ret);
+						assert.is_nil(err);
+					end
+				end);
+			end);
+
 			describe("archive stores", function ()
 				randomize(false);
 
@@ -100,7 +206,8 @@
 
 				local test_stanza = st.stanza("test", { xmlns = "urn:example:foo" })
 					:tag("foo"):up()
-					:tag("foo"):up();
+					:tag("foo"):up()
+					:reset();
 				local test_time = 1539204123;
 
 				local test_data = {
@@ -108,17 +215,22 @@
 					{ nil, test_stanza, test_time+1, "contact2@example.com" };
 					{ nil, test_stanza, test_time+2, "contact2@example.com" };
 					{ nil, test_stanza, test_time-1, "contact2@example.com" };
+					{ nil, test_stanza, test_time-1, "contact3@example.com" };
+					{ nil, test_stanza, test_time+0, "contact3@example.com" };
+					{ nil, test_stanza, test_time+1, "contact3@example.com" };
 				};
 
 				it("can be added to", function ()
 					for _, data_item in ipairs(test_data) do
-						local ok = archive:append("user", unpack(data_item, 1, 4));
-						assert.truthy(ok);
+						local id = archive:append("user", unpack(data_item, 1, 4));
+						assert.truthy(id);
+						data_item[1] = id;
 					end
 				end);
 
 				describe("can be queried", function ()
 					it("for all items", function ()
+						-- luacheck: ignore 211/err
 						local data, err = archive:find("user", {});
 						assert.truthy(data);
 						local count = 0;
@@ -135,6 +247,7 @@
 					end);
 
 					it("by JID", function ()
+						-- luacheck: ignore 211/err
 						local data, err = archive:find("user", {
 							with = "contact@example.com";
 						});
@@ -153,6 +266,7 @@
 					end);
 
 					it("by time (end)", function ()
+						-- luacheck: ignore 211/err
 						local data, err = archive:find("user", {
 							["end"] = test_time;
 						});
@@ -167,10 +281,11 @@
 							assert.equal(2, #item.tags);
 							assert(test_time >= when);
 						end
-						assert.equal(2, count);
+						assert.equal(4, count);
 					end);
 
 					it("by time (start)", function ()
+						-- luacheck: ignore 211/err
 						local data, err = archive:find("user", {
 							["start"] = test_time;
 						});
@@ -185,10 +300,11 @@
 							assert.equal(2, #item.tags);
 							assert(test_time <= when);
 						end
-						assert.equal(#test_data -1, count);
+						assert.equal(#test_data - 2, count);
 					end);
 
 					it("by time (start+end)", function ()
+						-- luacheck: ignore 211/err
 						local data, err = archive:find("user", {
 							["start"] = test_time;
 							["end"] = test_time+1;
@@ -205,8 +321,113 @@
 							assert(when >= test_time, ("%d >= %d"):format(when, test_time));
 							assert(when <= test_time+1, ("%d <= %d"):format(when, test_time+1));
 						end
+						assert.equal(4, count);
+					end);
+
+					it("by id (after)", function ()
+						-- luacheck: ignore 211/err
+						local data, err = archive:find("user", {
+							["after"] = test_data[2][1];
+						});
+						assert.truthy(data);
+						local count = 0;
+						for id, item in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert.equal(test_data[2+count][1], id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+						end
+						assert.equal(5, count);
+					end);
+
+					it("by id (before)", function ()
+						-- luacheck: ignore 211/err
+						local data, err = archive:find("user", {
+							["before"] = test_data[4][1];
+						});
+						assert.truthy(data);
+						local count = 0;
+						for id, item in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert.equal(test_data[count][1], id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+						end
+						assert.equal(3, count);
+					end);
+
+					it("by id (before and after) #full_id_range", function ()
+						assert.truthy(archive.caps and archive.caps.full_id_range, "full ID range support")
+						local data, err = archive:find("user", {
+								["after"] = test_data[1][1];
+								["before"] = test_data[4][1];
+							});
+						assert.truthy(data, err);
+						local count = 0;
+						for id, item in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert.equal(test_data[1+count][1], id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+						end
 						assert.equal(2, count);
 					end);
+
+					it("by multiple ids", function ()
+						assert.truthy(archive.caps and archive.caps.ids, "Multiple ID query")
+						local data, err = archive:find("user", {
+								["ids"] = {
+									test_data[2][1];
+									test_data[4][1];
+								};
+							});
+						assert.truthy(data, err);
+						local count = 0;
+						for id, item in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert.equal(test_data[count==1 and 2 or 4][1], id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+						end
+						assert.equal(2, count);
+
+					end);
+
+
+					it("can be queried in reverse", function ()
+
+						local data, err = archive:find("user", {
+								reverse = true;
+								limit = 3;
+							});
+						assert.truthy(data, err);
+
+						local i = #test_data;
+						for id, item in data do
+							assert.truthy(id);
+							assert.equal(test_data[i][1], id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+							i = i - 1;
+						end
+
+					end);
+
+
 				end);
 
 				it("can selectively delete items", function ()
@@ -239,6 +460,7 @@
 				end);
 
 				it("can be purged", function ()
+					-- luacheck: ignore 211/err
 					local ok, err = archive:delete("user");
 					assert.truthy(ok);
 					local data, err = archive:find("user", {
@@ -275,8 +497,9 @@
 				it("overwrites existing keys with new data", function ()
 					local prefix = ("a"):rep(50);
 					local username = "user-overwrite";
-					assert(archive:append(username, prefix.."-1", test_stanza, test_time, "contact@example.com"));
-					assert(archive:append(username, prefix.."-2", test_stanza, test_time, "contact@example.com"));
+					local a1 = assert(archive:append(username, prefix.."-1", test_stanza, test_time, "contact@example.com"));
+					local a2 = assert(archive:append(username, prefix.."-2", test_stanza, test_time, "contact@example.com"));
+					local ids = { a1, a2, };
 
 					do
 						local data = assert(archive:find(username, {}));
@@ -284,7 +507,7 @@
 						for id, item, when in data do --luacheck: ignore 213/when
 							count = count + 1;
 							assert.truthy(id);
-							assert.equals(("%s-%d"):format(prefix, count), id);
+							assert.equals(ids[count], id);
 							assert(st.is_stanza(item));
 						end
 						assert.equal(2, count);
@@ -292,7 +515,7 @@
 
 					local new_stanza = st.clone(test_stanza);
 					new_stanza.attr.foo = "bar";
-					assert(archive:append(username, prefix.."-2", new_stanza, test_time+1, "contact2@example.com"));
+					assert(archive:append(username, a2, new_stanza, test_time+1, "contact2@example.com"));
 
 					do
 						local data = assert(archive:find(username, {}));
@@ -300,7 +523,7 @@
 						for id, item, when in data do
 							count = count + 1;
 							assert.truthy(id);
-							assert.equals(("%s-%d"):format(prefix, count), id);
+							assert.equals(ids[count], id);
 							assert(st.is_stanza(item));
 							if count == 2 then
 								assert.equals(test_time+1, when);
@@ -326,6 +549,49 @@
 					assert.equal(2, count);
 					assert(archive:delete("user-issue1073"));
 				end);
+
+				it("can be treated as a map store", function ()
+					assert.falsy(archive:get("mapuser", "no-such-id"));
+					assert.falsy(archive:set("mapuser", "no-such-id", test_stanza));
+
+					local id = archive:append("mapuser", nil, test_stanza, test_time, "contact@example.com");
+					do
+						local stanza_roundtrip, when, with = archive:get("mapuser", id);
+						assert.same(tostring(test_stanza), tostring(stanza_roundtrip), "same stanza is returned");
+						assert.equal(test_time, when, "same 'when' is returned");
+						assert.equal("contact@example.com", with, "same 'with' is returned");
+					end
+
+					local replacement_stanza = st.stanza("test", { xmlns = "urn:example:foo" })
+						:tag("bar"):up()
+						:reset();
+					assert(archive:set("mapuser", id, replacement_stanza, test_time+1));
+
+					do
+						local replaced, when, with = archive:get("mapuser", id);
+						assert.same(tostring(replacement_stanza), tostring(replaced), "replaced stanza is returned");
+						assert.equal(test_time+1, when, "modified 'when' is returned");
+						assert.equal("contact@example.com", with, "original 'with' is returned");
+					end
+				end);
+
+				it("the summary api works", function()
+					assert.truthy(archive:delete("summary-user"));
+					local first_sid = archive:append("summary-user", nil, test_stanza, test_time, "contact@example.com");
+					local second_sid = archive:append("summary-user", nil, test_stanza, test_time+1, "contact@example.com");
+					assert.truthy(first_sid and second_sid, "preparations failed")
+					---
+
+					local user_summary, err = archive:summary("summary-user");
+					assert.is_table(user_summary, err);
+					assert.same({ ["contact@example.com"] = 2 }, user_summary.counts, "summary.counts matches");
+					assert.same({ ["contact@example.com"] = test_time }, user_summary.earliest, "summary.earliest matches");
+					assert.same({ ["contact@example.com"] = test_time+1 }, user_summary.latest, "summary.latest matches");
+					if user_summary.body then
+						assert.same({ ["contact@example.com"] = test_stanza:get_child_text("body") }, user_summary.body, "summary.body matches");
+					end
+				end);
+
 			end);
 		end);
 	end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/inputs/http/httpstream-chunked-test.txt	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,15 @@
+HTTP/1.1 200 OK
+Cache-Control: max-age=0, must-revalidate, private
+Content-Type: application/json
+Date: Fri, 21 Aug 2020 12:18:51 GMT
+Expires: Fri, 21 Aug 2020 12:18:51 GMT
+Server: Apache/2.4.38 (Debian)
+Set-Cookie: PHPSESSID=00000000000000000000000000; path=/; HttpOnly
+Strict-Transport-Security: max-age=29030400
+X-Powered-By: PHP/7.4.7
+Transfer-Encoding: chunked
+
+2b4d
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+0
+
--- a/spec/json/pass1.json	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/json/pass1.json	Mon Dec 12 07:07:13 2022 +0100
@@ -20,8 +20,8 @@
         "backslash": "\\",
         "controls": "\b\f\n\r\t",
         "slash": "/ & \/",
-        "alpha": "abcdefghijklmnopqrstuvwyz",
-        "ALPHA": "ABCDEFGHIJKLMNOPQRSTUVWYZ",
+        "alpha": "abcdefghijklmnopqrstuvwxyz",
+        "ALPHA": "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
         "digit": "0123456789",
         "0123456789": "digit",
         "special": "`1~!@#$%^&*()_+-={':[,]}|;.</>?",
@@ -55,4 +55,4 @@
 0.1e1,
 1e-1,
 1e00,2e+00,2e-00
-,"rosebud"]
\ No newline at end of file
+,"rosebud"]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/pass4.json	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,8 @@
+{
+  "one": [
+ 
+  ],
+  "two": [],
+  "three": [ ],
+  "four": [  ]
+}
--- a/spec/muc_util_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/muc_util_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -3,11 +3,23 @@
 local st = require "util.stanza";
 
 do
-	local old_pp = package.path;
-	package.path = "./?.lib.lua;"..package.path;
-	muc_util = require "plugins.muc.util";
-	package.path = old_pp;
-end
+	-- XXX Hack for lack of a mock moduleapi
+	local env = setmetatable({
+		module = {
+			_shared = {};
+			-- Close enough to the real module:shared() for our purposes here
+			shared = function (self, name)
+				local t = self._shared[name];
+				if t == nil then
+					t = {};
+					self._shared[name] = t;
+				end
+				return t;
+			end;
+		}
+	}, { __index = _ENV or _G });
+	muc_util = require "util.envload".envloadfile("plugins/muc/util.lib.lua", env)();
+	end
 
 describe("muc/util", function ()
 	describe("filter_muc_x()", function ()
--- a/spec/net_http_parser_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/net_http_parser_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,16 +1,105 @@
-local httpstreams = { [[
+local http_parser = require "net.http.parser";
+local sha1 = require "util.hashes".sha1;
+
+local parser_input_bytes = 3;
+
+local function CRLF(s)
+	return (s:gsub("\n", "\r\n"));
+end
+
+local function test_stream(stream, expect)
+	local success_cb = spy.new(function (packet)
+		assert.is_table(packet);
+		if packet.body ~= false then
+			assert.is_equal(expect.body, packet.body);
+		end
+	end);
+
+	local parser = http_parser.new(success_cb, error, stream:sub(1,4) == "HTTP" and "client" or "server")
+	for chunk in stream:gmatch("."..string.rep(".?", parser_input_bytes-1)) do
+		parser:feed(chunk);
+	end
+
+	assert.spy(success_cb).was_called(expect.count or 1);
+end
+
+
+describe("net.http.parser", function()
+	describe("parser", function()
+		it("should handle requests with no content-length or body", function ()
+			test_stream(
+CRLF[[
 GET / HTTP/1.1
 Host: example.com
 
-]], [[
+]],
+				{
+					body = "";
+				}
+			);
+		end);
+
+		it("should handle responses with empty body", function ()
+			test_stream(
+CRLF[[
 HTTP/1.1 200 OK
 Content-Length: 0
 
-]], [[
+]],
+				{
+					body = "";
+				}
+			);
+		end);
+
+		it("should handle simple responses", function ()
+			test_stream(
+
+CRLF[[
 HTTP/1.1 200 OK
 Content-Length: 7
 
 Hello
+]],
+				{
+					body = "Hello\r\n", count = 1;
+				}
+			);
+		end);
+
+		it("should handle chunked encoding in responses", function ()
+			test_stream(
+
+CRLF[[
+HTTP/1.1 200 OK
+Transfer-Encoding: chunked
+
+1
+H
+1
+e
+2
+ll
+1
+o
+0
+
+
+]],
+				{
+					body = "Hello", count = 2;
+				}
+			);
+		end);
+
+		it("should handle a stream of responses", function ()
+			test_stream(
+
+CRLF[[
+HTTP/1.1 200 OK
+Content-Length: 5
+
+Hello
 HTTP/1.1 200 OK
 Transfer-Encoding: chunked
 
@@ -25,28 +114,22 @@
 0
 
 
-]]
-}
-
-
-local http_parser = require "net.http.parser";
-
-describe("net.http.parser", function()
-	describe("#new()", function()
-		it("should work", function()
-			for _, stream in ipairs(httpstreams) do
-				local success;
-				local function success_cb(packet)
-					success = true;
-				end
-				stream = stream:gsub("\n", "\r\n");
-				local parser = http_parser.new(success_cb, error, stream:sub(1,4) == "HTTP" and "client" or "server")
-				for chunk in stream:gmatch("..?.?") do
-					parser:feed(chunk);
-				end
-
-				assert.is_true(success);
-			end
+]],
+				{
+					body = "Hello", count = 3;
+				}
+			);
 		end);
 	end);
+
+	it("should handle large chunked responses", function ()
+		local data = io.open("spec/inputs/http/httpstream-chunked-test.txt", "rb"):read("*a");
+
+		-- Just a sanity check... text editors and things may mess with line endings, etc.
+		assert.equal("25930f021785ae14053a322c2dbc1897c3769720", sha1(data, true), "test data malformed");
+
+		test_stream(data, {
+			body = string.rep("~", 11085), count = 2;
+		});
+	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/net_stun_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,97 @@
+local hex = require "util.hex";
+
+local function parse(pkt_desc)
+	local result = {};
+	for line in pkt_desc:gmatch("([^\n]+)\n") do
+		local b1, b2, b3, b4 = line:match("^%s*(%x%x) (%x%x) (%x%x) (%x%x)%s");
+		if b1 then
+			table.insert(result, b1);
+			table.insert(result, b2);
+			table.insert(result, b3);
+			table.insert(result, b4);
+		end
+	end
+	return hex.decode(table.concat(result));
+end
+
+local sample_packet = parse[[
+      00 01 00 60     Request type and message length
+      21 12 a4 42     Magic cookie
+      78 ad 34 33  }
+      c6 ad 72 c0  }  Transaction ID
+      29 da 41 2e  }
+      00 06 00 12     USERNAME attribute header
+      e3 83 9e e3  }
+      83 88 e3 83  }
+      aa e3 83 83  }  Username value (18 bytes) and padding (2 bytes)
+      e3 82 af e3  }
+      82 b9 00 00  }
+      00 15 00 1c     NONCE attribute header
+      66 2f 2f 34  }
+      39 39 6b 39  }
+      35 34 64 36  }
+      4f 4c 33 34  }  Nonce value
+      6f 4c 39 46  }
+      53 54 76 79  }
+      36 34 73 41  }
+      00 14 00 0b     REALM attribute header
+      65 78 61 6d  }
+      70 6c 65 2e  }  Realm value (11 bytes) and padding (1 byte)
+      6f 72 67 00  }
+      00 08 00 14     MESSAGE-INTEGRITY attribute header
+      f6 70 24 65  }
+      6d d6 4a 3e  }
+      02 b8 e0 71  }  HMAC-SHA1 fingerprint
+      2e 85 c9 a2  }
+      8c a8 96 66  }
+]];
+
+describe("net.stun", function ()
+	local stun = require "net.stun";
+
+	it("works", function ()
+		local packet = stun.new_packet();
+		assert.is_string(packet:serialize());
+	end);
+
+	it("can decode the sample packet", function ()
+		local packet = stun.new_packet():deserialize(sample_packet);
+		assert(packet);
+		local method, method_name = packet:get_method();
+		assert.equal(1, method);
+		assert.equal("binding", method_name);
+		assert.equal("example.org", packet:get_attribute("realm"));
+	end);
+
+	it("can generate the sample packet", function ()
+		-- These values, and the sample packet, come from RFC 5769 2.4
+		local username = string.char(
+			-- U+30DE KATAKANA LETTER MA
+			0xE3, 0x83, 0x9E,
+			-- U+30C8 KATAKANA LETTER TO
+			0xE3, 0x83, 0x88,
+			-- U+30EA KATAKANA LETTER RI
+			0xE3, 0x83, 0xAA,
+			-- U+30C3 KATAKANA LETTER SMALL TU
+			0xE3, 0x83, 0x83,
+			-- U+30AF KATAKANA LETTER KU
+			0xE3, 0x82, 0xAF,
+			-- U+30B9 KATAKANA LETTER SU
+			0xE3, 0x82, 0xB9
+		);
+
+		--    Password:  "The<U+00AD>M<U+00AA>tr<U+2168>" and "TheMatrIX" (without
+		--       quotes) respectively before and after SASLprep processing
+		local password = "TheMatrIX";
+		local realm = "example.org";
+
+		local p3 = stun.new_packet("binding", "request");
+		p3.transaction_id = hex.decode("78AD3433C6AD72C029DA412E");
+		p3:add_attribute("username", username);
+		p3:add_attribute("nonce", "f//499k954d6OL34oL9FSTvy64sA");
+		p3:add_attribute("realm", realm);
+		local key = stun.get_long_term_auth_key(realm, username, password);
+		p3:add_message_integrity(key);
+		assert.equal(sample_packet, p3:serialize());
+	end);
+end);
--- a/spec/net_websocket_frames_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/net_websocket_frames_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -52,6 +52,26 @@
 			["RSV2"] = false;
 			["RSV3"] = false;
 		};
+		ping = {
+			["opcode"] = 0x9;
+			["length"] = 4;
+			["data"] = "ping";
+			["FIN"] = true;
+			["MASK"] = false;
+			["RSV1"] = false;
+			["RSV2"] = false;
+			["RSV3"] = false;
+		};
+		pong = {
+			["opcode"] = 0xa;
+			["length"] = 4;
+			["data"] = "pong";
+			["FIN"] = true;
+			["MASK"] = false;
+			["RSV1"] = false;
+			["RSV2"] = false;
+			["RSV3"] = false;
+		};
 	}
 
 	describe("build", function ()
@@ -62,6 +82,8 @@
 			assert.equal("\128\0", build(test_frames.simple_fin));
 			assert.equal("\128\133 \0 \0HeLlO", build(test_frames.with_mask))
 			assert.equal("\128\128 \0 \0", build(test_frames.empty_with_mask))
+			assert.equal("\137\4ping", build(test_frames.ping));
+			assert.equal("\138\4pong", build(test_frames.pong));
 		end);
 	end);
 
@@ -72,6 +94,8 @@
 			assert.same(test_frames.simple_data, parse("\0\5hello"));
 			assert.same(test_frames.simple_fin, parse("\128\0"));
 			assert.same(test_frames.with_mask, parse("\128\133 \0 \0HeLlO"));
+			assert.same(test_frames.ping, parse("\137\4ping"));
+			assert.same(test_frames.pong, parse("\138\4pong"));
 		end);
 	end);
 
--- a/spec/scansion/basic_message.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/basic_message.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -79,7 +79,7 @@
 	<message from="${Romeo's full JID}" type="chat">
 		<body>Hello Juliet, are you there?</body>
 		<delay xmlns='urn:xmpp:delay' from='localhost' stamp='{scansion:any}' />
-	</message>	
+	</message>
 
 # Romeo sends another bare-JID message, it should be delivered
 # instantly to Juliet's phone
@@ -92,7 +92,7 @@
 Juliet's phone receives:
 	<message from="${Romeo's full JID}" type="chat">
 		<body>Oh, hi!</body>
-	</message>	
+	</message>
 
 # Juliet's laptop goes online, but with a negative priority
 
@@ -122,7 +122,7 @@
 Juliet's phone receives:
 	<message from="${Romeo's full JID}" type="chat">
 		<body>How are you?</body>
-	</message>	
+	</message>
 
 # Romeo sends direct to Juliet's full JID, and she should receive it
 
--- a/spec/scansion/blocking.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/blocking.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -145,16 +145,16 @@
 	</message>
 
 # Bye
-Juliet disconnects
-
 Juliet sends:
 	<presence type="unavailable"/>
 
+Juliet disconnects
+
 Romeo receives:
 	<presence from="${Juliet's full JID}" to="${Romeo's JID}" type="unavailable"/>
 
-Romeo disconnects
-
 Romeo sends:
 	<presence type="unavailable"/>
 
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/empty_bookmarks.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,27 @@
+# mod_scansion_record on host 'localhost' recording started 2022-07-26T21:39:55Z
+
+[Client] Romeo
+	password: password
+	jid: juliet@localhost/UaksS4M1xYZB
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq xml:lang='en' type='get' id='bNBJLtpIJXpq'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<items node='storage:bookmarks'/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id='bNBJLtpIJXpq' type='error'>
+		<error type='cancel'>
+			<item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+Romeo disconnects
+
+# recording ended on 2022-07-26T21:40:45Z
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/extdisco.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,71 @@
+# XEP-0215: External Service Discovery
+
+[Client] Romeo
+	password: password
+	jid: user@localhost/mFquWxSr
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq type='get' xml:lang='sv' id='lx2' to='localhost'>
+		<services xmlns='urn:xmpp:extdisco:2'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='lx2' from='localhost'>
+		<services xmlns='urn:xmpp:extdisco:2'>
+			<service host='default.example' transport='udp' port='9876' type='stun'/>
+			<service port='9876' type='turn' restricted='1' password='yHYYBDN7M3mdlug0LTdJbW0GvvQ=' transport='udp' host='default.example' username='1219525744'/>
+			<service port='9876' type='turn' restricted='1' password='1Uc6QfrDhIlbK97rGCUQ/cUICxs=' transport='udp' host='default.example' username='1219525744'/>
+			<service port='2121' type='ftp' restricted='1' password='password' transport='tcp' host='default.example' username='john'/>
+			<service port='21' type='ftp' restricted='1' password='password' transport='tcp' host='ftp.example.com' username='john'/>
+		</services>
+	</iq>
+
+Romeo sends:
+	<iq type='get' xml:lang='sv' id='lx3' to='localhost'>
+		<services xmlns='urn:xmpp:extdisco:2' type='ftp'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='lx3' from='localhost'>
+		<services xmlns='urn:xmpp:extdisco:2'>
+			<service port='2121' type='ftp' restricted='1' password='password' transport='tcp' host='default.example' username='john'/>
+			<service port='21' type='ftp' restricted='1' password='password' transport='tcp' host='ftp.example.com' username='john'/>
+		</services>
+	</iq>
+
+Romeo sends:
+	<iq type='get' xml:lang='sv' id='lx4' to='localhost'>
+		<credentials xmlns='urn:xmpp:extdisco:2'>
+			<service host='default.example' type='turn'/>
+		</credentials>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='lx4' from='localhost'>
+		<credentials xmlns='urn:xmpp:extdisco:2'>
+			<service port='9876' type='turn' restricted='1' password='yHYYBDN7M3mdlug0LTdJbW0GvvQ=' transport='udp' host='default.example' username='1219525744'/>
+			<service port='9876' type='turn' restricted='1' password='1Uc6QfrDhIlbK97rGCUQ/cUICxs=' transport='udp' host='default.example' username='1219525744'/>
+		</credentials>
+	</iq>
+
+Romeo sends:
+	<iq type='get' xml:lang='sv' id='lx5' to='localhost'>
+		<credentials xmlns='urn:xmpp:extdisco:2'>
+			<service host='default.example' />
+		</credentials>
+	</iq>
+
+Romeo receives:
+	<iq type='error' id='lx5' from='localhost'>
+		<error type='modify'>
+			<bad-request xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+Romeo disconnects
+
+# recording ended on 2020-07-18T16:47:57Z
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/http_upload.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,83 @@
+# XEP-0363 HTTP Upload with mod_http_file_share
+
+[Client] Romeo
+	password: password
+	jid: filesharingenthusiast@localhost/krxLaE3s
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq to='upload.localhost' type='get' id='932c02fe-4461-4ad4-9c85-54863294b4dc' xml:lang='en'>
+		<request content-type='text/plain' filename='verysmall.dat' xmlns='urn:xmpp:http:upload:0' size='5'/>
+	</iq>
+
+Romeo receives:
+	<iq id='932c02fe-4461-4ad4-9c85-54863294b4dc' from='upload.localhost' type='result'>
+		<slot xmlns='urn:xmpp:http:upload:0'>
+			<get url='{scansion:any}'/>
+			<put url='{scansion:any}'>
+				<header name='Authorization'></header>
+			</put>
+		</slot>
+	</iq>
+
+Romeo sends:
+	<iq to='upload.localhost' type='get' id='46ca64f3-518e-42bd-8e2f-4ab2f6d2224f' xml:lang='en'>
+		<request content-type='text/plain' filename='toolarge.dat' xmlns='urn:xmpp:http:upload:0' size='10000000000'/>
+	</iq>
+
+Romeo receives:
+	<iq id='46ca64f3-518e-42bd-8e2f-4ab2f6d2224f' from='upload.localhost' type='error'>
+		<error type='modify'>
+			<not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>File too large</text>
+			<file-too-large xmlns='urn:xmpp:http:upload:0'>
+				<max-file-size>10000000</max-file-size>
+			</file-too-large>
+		</error>
+	</iq>
+
+Romeo sends:
+	<iq to='upload.localhost' type='get' id='497c20dd-dda2-4feb-8199-7086e203de46' xml:lang='en'>
+		<request content-type='text/plain' filename='negative.dat' xmlns='urn:xmpp:http:upload:0' size='-1000'/>
+	</iq>
+
+Romeo receives:
+	<iq id='497c20dd-dda2-4feb-8199-7086e203de46' from='upload.localhost' type='error'>
+		<error type='modify'>
+			<bad-request xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>File size must be positive integer</text>
+		</error>
+	</iq>
+
+Romeo sends:
+	<iq to='upload.localhost' type='get' id='ac56d83f-a627-4732-8399-60492d1210b6' xml:lang='en'>
+		<request content-type='text/plain' filename='invalid/filename.dat' xmlns='urn:xmpp:http:upload:0' size='1000'/>
+	</iq>
+
+Romeo receives:
+	<iq id='ac56d83f-a627-4732-8399-60492d1210b6' from='upload.localhost' type='error'>
+		<error type='modify'>
+			<bad-request xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Invalid filename</text>
+		</error>
+	</iq>
+
+Romeo sends:
+	<iq to='upload.localhost' type='get' id='1401d3b5-7973-486f-85b3-3e63d13c7f0e' xml:lang='en'>
+		<request content-type='application/x-executable' filename='evil.exe' xmlns='urn:xmpp:http:upload:0' size='1000'/>
+	</iq>
+
+Romeo receives:
+	<iq id='1401d3b5-7973-486f-85b3-3e63d13c7f0e' from='upload.localhost' type='error'>
+		<error type='modify'>
+			<not-acceptable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>File type not allowed</text>
+		</error>
+	</iq>
+
+Romeo disconnects
+
+# recording ended on 2021-01-27T22:10:46Z
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/issue1121.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,75 @@
+# When removing roster contact, Prosody should send directed "unavailable" presence but sends global unavailable presence
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends
+	<presence/>
+
+Romeo receives
+	<presence from="${Romeo's full JID}"/>
+
+Juliet connects
+
+Juliet sends
+	<presence/>
+
+Juliet receives
+	<presence from="${Juliet's full JID}"/>
+
+Romeo sends
+	<presence to="juliet@localhost" type="subscribe"/>
+
+Romeo receives
+	<presence from="juliet@localhost" to="romeo@localhost"/>
+
+Juliet receives
+	<presence from="romeo@localhost" to="juliet@localhost" type="subscribe"/>
+
+Juliet sends
+	<presence to="romeo@localhost" type="subscribed"/>
+
+Romeo receives
+	<presence from="${Juliet's full JID}" to="romeo@localhost"/>
+
+Juliet sends
+	<presence to="romeo@localhost" type="subscribe"/>
+
+Juliet receives
+	<presence from="romeo@localhost" to="juliet@localhost"/>
+
+Romeo receives
+	<presence from="juliet@localhost" to="romeo@localhost" type="subscribe"/>
+
+Romeo sends
+	<presence to="juliet@localhost" type="subscribed"/>
+
+Juliet receives
+	<presence from="${Romeo's full JID}" to="juliet@localhost"/>
+
+Romeo receives
+	<presence from="${Juliet's full JID}" to="romeo@localhost"/>
+
+Juliet sends
+	<iq type="set" id="iq1">
+		<query xmlns="jabber:iq:roster">
+			<item jid="romeo@localhost" subscription="remove"/>
+		</query>
+	</iq>
+
+Juliet receives
+	<iq type="result" id="iq1"/>
+
+Romeo receives
+	<presence from="${Juliet's full JID}" to="romeo@localhost" type="unavailable"/>
+
+Romeo disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/keep_full_sub_req.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,58 @@
+# server MUST keep a record of the complete presence stanza comprising the subscription request (#689)
+
+[Client] Alice
+	jid: pars-a@localhost
+	password: password
+
+[Client] Bob
+	jid: pars-b@localhost
+	password: password
+
+[Client] Bob's phone
+	jid: pars-b@localhost/phone
+	password: password
+
+---------
+
+Alice connects
+
+Alice sends:
+	<presence to="${Bob's JID}" type="subscribe">
+		<preauth xmlns="urn:xmpp:pars:0" token="1tMFqYDdKhfe2pwp" />
+	</presence>
+
+Alice disconnects
+
+Bob connects
+
+Bob sends:
+	<presence/>
+
+Bob receives:
+	<presence from="${Bob's full JID}"/>
+
+Bob receives:
+	<presence from="${Alice's JID}" type="subscribe">
+		<preauth xmlns="urn:xmpp:pars:0" token="1tMFqYDdKhfe2pwp" />
+	</presence>
+
+Bob disconnects
+
+# Works if they reconnect too
+
+Bob's phone connects
+
+Bob's phone sends:
+	<presence/>
+
+Bob's phone receives:
+	<presence from="${Bob's phone's full JID}"/>
+
+
+Bob's phone receives:
+	<presence from="${Alice's JID}" type="subscribe">
+		<preauth xmlns="urn:xmpp:pars:0" token="1tMFqYDdKhfe2pwp" />
+	</presence>
+
+Bob's phone disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/lastactivity.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,45 @@
+# XEP-0012: Last Activity / mod_lastactivity
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence>
+		<status>Hello</status>
+	</presence>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}">
+		<status>Hello</status>
+	</presence>
+
+Romeo sends:
+	<presence type="unavailable">
+		<status>Goodbye</status>
+	</presence>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}" type="unavailable">
+		<status>Goodbye</status>
+	</presence>
+
+# mod_lastlog saves time + status message from the last unavailable presence
+
+Romeo sends:
+	<iq id='a' type='get'>
+		<query xmlns='jabber:iq:last'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='a'>
+		<query xmlns='jabber:iq:last' seconds='0'>Goodbye</query>
+	</iq>
+
+Romeo disconnects
+
+# recording ended on 2020-04-20T14:39:47Z
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/mam_extended.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,126 @@
+# MAM 0.7.x Extended features
+
+[Client] Romeo
+	jid: extmamtester@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+# Enable MAM so we can save some messages
+Romeo sends:
+	<iq type="set" id="enablemam">
+		<prefs xmlns="urn:xmpp:mam:2" default="always">
+			<always/>
+			<never/>
+		</prefs>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="enablemam">
+		<prefs xmlns="urn:xmpp:mam:2" default="always">
+			<always/>
+			<never/>
+		</prefs>
+	</iq>
+
+# Some messages to look for later
+Romeo sends:
+	<message to="someone@localhost" type="chat" id="chat01">
+		<body>Hello</body>
+	</message>
+
+Romeo sends:
+	<message to="someone@localhost" type="chat" id="chat02">
+		<body>U there?</body>
+	</message>
+
+# Metadata
+Romeo sends:
+	<iq type="get" id="mamextmeta">
+		<metadata xmlns="urn:xmpp:mam:2"/>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="mamextmeta">
+		<metadata xmlns="urn:xmpp:mam:2">
+			<start timestamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:mam:2" id="{scansion:any}"/>
+			<end timestamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:mam:2" id="{scansion:any}"/>
+		</metadata>
+	</iq>
+
+Romeo sends:
+	<iq type="set" id="mamquery1">
+		<query xmlns="urn:xmpp:mam:2" queryid="q1"/>
+	</iq>
+
+Romeo receives:
+	<message to="${Romeo's full JID}">
+		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
+			<forwarded xmlns="urn:xmpp:forward:0">
+				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat01" from="${Romeo's full JID}">
+					<body>Hello</body>
+				</message>
+			</forwarded>
+		</result>
+	</message>
+
+Romeo receives:
+	<message to="${Romeo's full JID}">
+		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
+			<forwarded xmlns="urn:xmpp:forward:0">
+				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat02" from="${Romeo's full JID}">
+					<body>U there?</body>
+				</message>
+			</forwarded>
+		</result>
+	</message>
+
+# FIXME unstable tag order from util.rsm
+Romeo receives:
+	<iq type="result" id="mamquery1" to="${Romeo's full JID}">
+		<fin xmlns="urn:xmpp:mam:2" complete="true" scansion:strict="false">
+		</fin>
+	</iq>
+
+# Get results in reverse order
+Romeo sends:
+	<iq type="set" id="mamquery2">
+		<query xmlns="urn:xmpp:mam:2" queryid="q1">
+			<flip-page/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<message to="${Romeo's full JID}">
+		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
+			<forwarded xmlns="urn:xmpp:forward:0">
+				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat02" from="${Romeo's full JID}">
+					<body>U there?</body>
+				</message>
+			</forwarded>
+		</result>
+	</message>
+
+Romeo receives:
+	<message to="${Romeo's full JID}">
+		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
+			<forwarded xmlns="urn:xmpp:forward:0">
+				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat01" from="${Romeo's full JID}">
+					<body>Hello</body>
+				</message>
+			</forwarded>
+		</result>
+	</message>
+
+# FIXME unstable tag order from util.rsm
+Romeo receives:
+	<iq type="result" id="mamquery2" to="${Romeo's full JID}">
+		<fin xmlns="urn:xmpp:mam:2" complete="true" scansion:strict="false">
+		</fin>
+	</iq>
--- a/spec/scansion/mam_prefs_prep.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/mam_prefs_prep.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -1,4 +1,4 @@
-# mod_mam shold apply JIDprep in prefs
+# mod_mam should apply JIDprep in prefs
 
 [Client] Romeo
 	jid: romeo@localhost
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_create_destroy.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,316 @@
+# MUC creation, basic messages and destruction
+
+[Client] Romeo
+	jid: romeo@localhost/mK0dD6Ha
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost/lVwkim_k
+	password: password
+
+[Client] Admin
+	jid: admin@localhost/DfNgg9VE
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="garden@conference.localhost/romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from="garden@conference.localhost/romeo">
+		<x xmlns="vcard-temp:x:update">
+			<photo/>
+		</x>
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<status code="201"/>
+			<item affiliation="owner" jid="${Romeo's full JID}" role="moderator"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message from="garden@conference.localhost" type="groupchat">
+		<subject/>
+	</message>
+
+Romeo sends:
+	<iq to="garden@conference.localhost" id="lx3" type="set">
+		<query xmlns="http://jabber.org/protocol/muc#owner">
+			<x type="submit" xmlns="jabber:x:data"/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="lx3" type="result" from="garden@conference.localhost"/>
+
+Juliet connects
+
+Romeo sends:
+	<message to="garden@conference.localhost" type="groupchat" id="rm1">
+		<body>Where are thou my Juliet?</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="garden@conference.localhost/romeo" id="rm1">
+		<body>Where are thou my Juliet?</body>
+	</message>
+
+Juliet sends:
+	<presence to="garden@conference.localhost/juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="garden@conference.localhost/romeo">
+		<x xmlns="vcard-temp:x:update">
+			<photo/>
+		</x>
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item affiliation="owner" role="moderator"/>
+		</x>
+	</presence>
+
+Juliet receives:
+	<presence from="garden@conference.localhost/juliet">
+		<x xmlns="vcard-temp:x:update">
+			<photo/>
+		</x>
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item affiliation="none" jid="${Juliet's full JID}" role="participant"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Juliet receives:
+	<message from="garden@conference.localhost/romeo" id="rm1" type="groupchat">
+		<body>Where are thou my Juliet?</body>
+		<delay stamp="{scansion:any}" xmlns="urn:xmpp:delay" from="garden@conference.localhost"/>
+	</message>
+
+Juliet receives:
+	<message from="garden@conference.localhost" type="groupchat">
+		<subject/>
+	</message>
+
+Romeo receives:
+	<presence from="garden@conference.localhost/juliet">
+		<x xmlns="vcard-temp:x:update">
+			<photo/>
+		</x>
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item affiliation="none" jid="${Juliet's full JID}" role="participant"/>
+		</x>
+	</presence>
+
+Juliet sends:
+	<message to="garden@conference.localhost" type="groupchat" id="jm1">
+		<body>/me jumps out from behind a tree</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="jm1" from="garden@conference.localhost/juliet">
+		<body>/me jumps out from behind a tree</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="jm1" from="garden@conference.localhost/juliet">
+		<body>/me jumps out from behind a tree</body>
+	</message>
+
+Juliet sends:
+	<message to="garden@conference.localhost" type="groupchat" id="jm2">
+		<body>Here I am!</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="jm2" from="garden@conference.localhost/juliet">
+		<body>Here I am!</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="jm2" from="garden@conference.localhost/juliet">
+		<body>Here I am!</body>
+	</message>
+
+Romeo sends:
+	<message to="garden@conference.localhost" type="groupchat" id="rm2">
+		<body>What is this place?</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="rm2" from="garden@conference.localhost/romeo">
+		<body>What is this place?</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="rm2" from="garden@conference.localhost/romeo">
+		<body>What is this place?</body>
+	</message>
+
+Juliet sends:
+	<message to="garden@conference.localhost" type="groupchat" id="jm3">
+		<body>I think we&apos;re in a script!</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="jm3" from="garden@conference.localhost/juliet">
+		<body>I think we&apos;re in a script!</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="jm3" from="garden@conference.localhost/juliet">
+		<body>I think we&apos;re in a script!</body>
+	</message>
+
+Romeo sends:
+	<message to="garden@conference.localhost" type="groupchat" id="rm3">
+		<body>Oh no! Does that mean our love is not real?!</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="rm3" from="garden@conference.localhost/romeo">
+		<body>Oh no! Does that mean our love is not real?!</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="rm3" from="garden@conference.localhost/romeo">
+		<body>Oh no! Does that mean our love is not real?!</body>
+	</message>
+
+Juliet sends:
+	<message to="garden@conference.localhost" type="groupchat" id="jm4">
+		<body>I refuse to accept this! Let&apos;s burn this place to the ground!</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="jm4" from="garden@conference.localhost/juliet">
+		<body>I refuse to accept this! Let&apos;s burn this place to the ground!</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="jm4" from="garden@conference.localhost/juliet">
+		<body>I refuse to accept this! Let&apos;s burn this place to the ground!</body>
+	</message>
+
+Romeo sends:
+	<message to="garden@conference.localhost" type="groupchat" id="rm4">
+		<body>Yes!</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" id="rm4" from="garden@conference.localhost/romeo">
+		<body>Yes!</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" id="rm4" from="garden@conference.localhost/romeo">
+		<body>Yes!</body>
+	</message>
+
+Romeo sends:
+	<iq to="garden@conference.localhost" id="lx4" type="set">
+		<query xmlns="http://jabber.org/protocol/muc#owner">
+			<destroy>
+				<reason>We refuse to live in this fantasy!</reason>
+			</destroy>
+		</query>
+	</iq>
+
+Juliet receives:
+	<presence from="garden@conference.localhost/juliet" type="unavailable">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<destroy>
+				<reason>We refuse to live in this fantasy!</reason>
+			</destroy>
+			<item affiliation="none" jid="${Juliet's full JID}" role="none"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<presence from="garden@conference.localhost/romeo" type="unavailable">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<destroy>
+				<reason>We refuse to live in this fantasy!</reason>
+			</destroy>
+			<item affiliation="owner" jid="${Romeo's full JID}" role="none"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq id="lx4" type="result" from="garden@conference.localhost"/>
+
+Juliet disconnects
+
+Romeo sends:
+	<presence to="elsewhere@conference.localhost/romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from="elsewhere@conference.localhost/romeo">
+		<x xmlns="vcard-temp:x:update">
+			<photo/>
+		</x>
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<status code="201"/>
+			<item affiliation="owner" jid="${Romeo's full JID}" role="moderator"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message from="elsewhere@conference.localhost" type="groupchat">
+		<subject/>
+	</message>
+
+Romeo sends:
+	<iq to="elsewhere@conference.localhost" id="lx5" type="set">
+		<query xmlns="http://jabber.org/protocol/muc#owner">
+			<x type="submit" xmlns="jabber:x:data"/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="lx5" type="result" from="elsewhere@conference.localhost"/>
+
+Admin connects
+
+Admin sends:
+	<iq id="destroy" type="set" to="conference.localhost">
+		<command xmlns="http://jabber.org/protocol/commands" node="http://prosody.im/protocol/muc#destroy">
+			<x xmlns="jabber:x:data">
+				<field var="rooms">
+					<value>elsewhere@conference.localhost</value>
+				</field>
+			</x>
+		</command>
+	</iq>
+
+Romeo receives:
+	<presence from="elsewhere@conference.localhost/romeo" type="unavailable">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<destroy/>
+			<item affiliation="owner" jid="${Romeo's full JID}" role="none"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo disconnects
+
+Admin receives:
+	<iq id="destroy" type="result" from="conference.localhost">
+		<command xmlns="http://jabber.org/protocol/commands" node="http://prosody.im/protocol/muc#destroy" status="completed" sessionid="{scansion:any}">
+			<note type="info">The following rooms were destroyed:&#10;elsewhere@conference.localhost</note>
+		</command>
+	</iq>
+
+Admin disconnects
+
+# recording ended on 2019-08-31T13:45:32Z
--- a/spec/scansion/muc_members_only_change.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/muc_members_only_change.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -94,7 +94,7 @@
 			<item affiliation='none' jid="${Juliet's JID}" />
 		</query>
 	</iq>
-	
+
 # As a non-member, Juliet must now be removed from the room
 Romeo receives:
 	<presence type='unavailable' from='room@conference.localhost/Juliet'>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_nickname_change.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,127 @@
+# MUC: Change nickname
+# Make sure a role is not reset, see #1466
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_moderatedroom'>
+					<value>1</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result"/>
+
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item role='moderator' xmlns='http://jabber.org/protocol/muc#user' affiliation='owner'/>
+		</x>
+	</presence>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='none' role='visitor'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation="none" role="visitor" jid="${Juliet's full JID}"/>
+		</x>
+	</presence>
+
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_moderatedroom'>
+					<value>0</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result"/>
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status xmlns='http://jabber.org/protocol/muc#user' code='104'/>
+		</x>
+	</message>
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet2">
+	</presence>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet' type='unavailable'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='303'/>
+			<item nick='Juliet2' jid="${Juliet's full JID}" affiliation='none' role='visitor'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet2'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='none' role='visitor'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_nickname_robotface.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,46 @@
+# MUC: Prevent nicknames failing strict resourceprep
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Roboteo
+	jid: bot@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="nobots@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='nobots@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='nobots@conference.localhost'><subject/></message>
+
+Roboteo connects
+
+Roboteo sends:
+	<presence to="nobots@conference.localhost/🤖️">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Roboteo receives:
+	<presence type='error' from='nobots@conference.localhost/🤖'>
+		<error by='nobots@conference.localhost' type='modify'>
+			<jid-malformed xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Nickname must pass strict validation</text>
+		</error>
+	</presence>
+
--- a/spec/scansion/muc_password.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/muc_password.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -58,7 +58,7 @@
 
 Juliet receives:
 	<presence from="room@conference.localhost/Juliet" type="error">
-		<error type="auth" code="401">
+		<error type="auth" by="room@conference.localhost">
 			<not-authorized xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
 		</error>
 	</presence>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_presence_probe.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,178 @@
+# #1535 Let MUCs respond to presence probes
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+[Client] Mercutio
+	jid: user3@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+# Romeo joins the MUC
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Disable presences for non-mods
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_presencebroadcast'>
+					<value>moderator</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Romeo probes himself
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo" type="probe">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+		</x>
+	</presence>
+
+# Juliet tries to probe Romeo before joining the room
+
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Romeo" type="probe">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" type="error">
+		<error type="cancel">
+			<not-acceptable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+		</error>
+	</presence>
+
+# Juliet tries to probe Mercutio (who's not in the MUC) before joining the room
+
+Juliet sends:
+	<presence to="room@conference.localhost/Mercutio" type="probe">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Mercutio" type="error">
+		<error type="cancel">
+			<not-acceptable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+		</error>
+	</presence>
+
+# Juliet joins the room
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Romeo probes Juliet
+
+Romeo sends:
+	<presence to="room@conference.localhost/Juliet" type="probe">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='none' role='participant'/>
+		</x>
+	</presence>
+
+
+# Mercutio tries to probe himself in a MUC before joining
+
+Mercutio connects
+
+Mercutio sends:
+	<presence to="room@conference.localhost/Mercutio" type="probe">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Mercutio receives:
+	<presence from="room@conference.localhost/Mercutio" type="error">
+		<error type="cancel">
+			<not-acceptable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+		</error>
+	</presence>
+
+
+# Romeo makes Mercutio a member and registers his nickname
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Mercutio's JID}" nick="Mercutio"/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Mercutio's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+
+# Romeo probes Mercutio, even though he's unavailable
+
+Romeo sends:
+	<presence to="room@conference.localhost/Mercutio" type="probe">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Mercutio' type="unavailable">
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item nick="Mercutio" affiliation='member' role='none' jid="${Mercutio's JID}" />
+		</x>
+	</presence>
--- a/spec/scansion/muc_register.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/muc_register.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -100,7 +100,9 @@
 				<field type='hidden' var='FORM_TYPE'>
 					<value>http://jabber.org/protocol/muc#register</value>
 				</field>
-				<field type='text-single' label='Nickname' var='muc#register_roomnick'/>
+				<field type='text-single' label='Nickname' var='muc#register_roomnick'>
+					<required/>
+				</field>
 			</x>
 		</query>
 	</iq>
@@ -175,10 +177,9 @@
 
 Rosaline receives:
 	<presence type='error' from='room@conference.localhost/Juliet'>
-		<error type='cancel'>
+		<error type='cancel' by='room@conference.localhost'>
 			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
 		</error>
-		<x xmlns='http://jabber.org/protocol/muc'/>
 	</presence>
 
 # In a heated moment, Juliet unregisters from the room
@@ -286,10 +287,9 @@
 
 Rosaline receives:
 	<presence type='error' from='room@conference.localhost/Juliet'>
-		<error type='cancel'>
+		<error type='cancel' by='room@conference.localhost'>
 			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
 		</error>
-		<x xmlns='http://jabber.org/protocol/muc'/>
 	</presence>
 
 # Juliet, however, quietly joins the room with success
@@ -326,7 +326,7 @@
 	</iq>
 
 # Romeo updates his own registration
-	
+
 Romeo sends:
 	<iq id='jw81b36f' to='room@conference.localhost' type='get'>
 		<query xmlns='jabber:iq:register'/>
@@ -339,7 +339,9 @@
 				<field type='hidden' var='FORM_TYPE'>
 					<value>http://jabber.org/protocol/muc#register</value>
 				</field>
-				<field type='text-single' label='Nickname' var='muc#register_roomnick'/>
+				<field type='text-single' label='Nickname' var='muc#register_roomnick'>
+					<required/>
+				</field>
 			</x>
 		</query>
 	</iq>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_show_offline.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,542 @@
+# MUC: Room registration and presence broadcast of unavailable members
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+[Client] Rosaline
+	jid: user3@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_presencebroadcast'>
+					<value>none</value>
+					<value>participant</value>
+					<value>moderator</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Juliet retrieves the registration form
+
+Juliet sends:
+	<iq id='jw81b36f' to='room@conference.localhost' type='get'>
+		<query xmlns='jabber:iq:register'/>
+	</iq>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='jw81b36f'>
+		<query xmlns='jabber:iq:register'>
+			<x type='form' xmlns='jabber:x:data'>
+				<field type='hidden' var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field type='text-single' label='Nickname' var='muc#register_roomnick'>
+					<required/>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Juliet sends:
+	<iq id='nv71va54' to='room@conference.localhost' type='set'>
+		<query xmlns='jabber:iq:register'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field var='muc#register_roomnick'>
+					<value>Juliet</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' jid="${Juliet's full JID}" role='participant'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='nv71va54'/>
+
+# Juliet discovers her reserved nick
+
+Juliet sends:
+	<iq id='getnick1' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+	</iq>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='getnick1'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'>
+			<identity category='conference' name='Juliet' type='text'/>
+		</query>
+	</iq>
+
+# Juliet leaves the room:
+
+Juliet sends:
+	<presence type="unavailable" to="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='member' role='none'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='member' role='participant'/>
+		</x>
+	</presence>
+
+# Rosaline connect and tries to join the room as Juliet
+
+Rosaline connects
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Rosaline receives:
+	<presence type='error' from='room@conference.localhost/Juliet'>
+		<error type='cancel' by='room@conference.localhost'>
+			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</presence>
+
+# In a heated moment, Juliet unregisters from the room
+
+Juliet sends:
+	<iq type='set' to='room@conference.localhost' id='unreg1'>
+		<query xmlns='jabber:iq:register'>
+			<remove/>
+		</query>
+	</iq>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='unreg1'/>
+
+# Romeo is notified of Juliet's sad decision
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='true'>
+			<item jid="${Juliet's JID}" affiliation='none' />
+		</x>
+	</message>
+
+# Rosaline attempts once more to sneak into the room, disguised as Juliet
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='owner' role='moderator'/>
+		</x>
+	</presence>
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='none' jid="${Rosaline's full JID}" role='participant'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='none' jid="${Rosaline's full JID}" role='participant'/>
+		</x>
+	</presence>
+
+# On discovering the ruse, Romeo restores Juliet's nick and status within the room
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" nick='Juliet' />
+		</query>
+	</iq>
+
+# Rosaline is evicted from the room
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet' type='unavailable'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='307'/>
+			<item affiliation='none' role='none' jid="${Rosaline's full JID}">
+				<reason>This nickname is reserved</reason>
+			</item>
+		</x>
+	</presence>
+
+# An out-of-room affiliation change is received for Juliet
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq type='result' id='member1' from='room@conference.localhost' />
+
+Rosaline receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='307'/>
+			<item affiliation='none' jid="${Rosaline's full JID}" role='none'>
+				<reason>This nickname is reserved</reason>
+			</item>
+			<status code='110'/>
+		</x>
+	</presence>
+
+# Rosaline, frustrated, attempts to get back into the room...
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+# ...but once again, is denied
+
+Rosaline receives:
+	<presence type='error' from='room@conference.localhost/Juliet'>
+		<error type='cancel' by='room@conference.localhost'>
+			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</presence>
+
+# Juliet, however, quietly joins the room with success
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Romeo checks whether he has reserved his own nick yet
+
+Romeo sends:
+	<iq id='getnick1' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+	</iq>
+
+# But no nick is returned, as he hasn't registered yet!
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='getnick1'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item' scansion:strict='true' />
+	</iq>
+
+# Romeo updates his own registration
+
+Romeo sends:
+	<iq id='jw81b36f' to='room@conference.localhost' type='get'>
+		<query xmlns='jabber:iq:register'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='jw81b36f'>
+		<query xmlns='jabber:iq:register'>
+			<x type='form' xmlns='jabber:x:data'>
+				<field type='hidden' var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field type='text-single' label='Nickname' var='muc#register_roomnick'>
+					<required/>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo sends:
+	<iq id='nv71va54' to='room@conference.localhost' type='set'>
+		<query xmlns='jabber:iq:register'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field var='muc#register_roomnick'>
+					<value>Romeo</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='owner' jid="${Romeo's full JID}" role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='nv71va54'/>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item role='moderator' xmlns='http://jabber.org/protocol/muc#user' affiliation='owner'/>
+		</x>
+	</presence>
+
+# Romeo discovers his reserved nick
+
+Romeo sends:
+	<iq id='getnick1' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='getnick1'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'>
+			<identity category='conference' name='Romeo' type='text'/>
+		</query>
+	</iq>
+
+# To check the status of the room is as expected, Romeo requests the member list
+
+Romeo sends:
+	<iq id='member3' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member'/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq from='room@conference.localhost' type='result' id='member3'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item jid="${Juliet's JID}" affiliation='member' nick='Juliet'/>
+		</query>
+	</iq>
+
+Juliet sends:
+	<presence type="unavailable" to="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet' type='unavailable' />
+
+Romeo receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet' />
+
+# Rosaline joins as herself
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Rosaline">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Rosaline receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Juliet' type='unavailable'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='none' nick='Juliet' xmlns='http://jabber.org/protocol/muc#user'/>
+		</x>
+	</presence>
+
+Rosaline receives:
+	<presence from="room@conference.localhost/Rosaline" />
+
+Rosaline receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Rosaline'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Rosaline's full JID}" affiliation='none' role='participant'/>
+		</x>
+	</presence>
+
+# Rosaline tries to register her own nickname, but unaffiliated
+# registration is disabled by default
+
+Rosaline sends:
+	<iq id='reg990' to='room@conference.localhost' type='get'>
+		<query xmlns='jabber:iq:register'/>
+	</iq>
+
+Rosaline receives:
+	<iq type='error' from='room@conference.localhost' id='reg990'>
+		<error type='auth'>
+			<registration-required xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+Rosaline sends:
+	<iq id='reg991' to='room@conference.localhost' type='set'>
+		<query xmlns='jabber:iq:register'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field var='muc#register_roomnick'>
+					<value>Romeo</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Rosaline receives:
+	<iq id='reg991' type='error'>
+		<error type='auth'>
+			<registration-required xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+# Romeo reserves her nickname for her
+
+Romeo sends:
+	<iq id='member2' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Rosaline's JID}" nick='Rosaline' />
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Rosaline'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='participant' jid="${Rosaline's full JID}">
+				<actor jid="${Romeo's full JID}" nick='Romeo'/>
+			</item>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq type='result' id='member2' from='room@conference.localhost' />
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Rosaline'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='participant' jid="${Rosaline's full JID}">
+				<actor nick='Romeo' />
+			</item>
+			<status xmlns='http://jabber.org/protocol/muc#user' code='110'/>
+		</x>
+	</presence>
+
+# Romeo sets their their own nickname via admin query (see #1273)
+Romeo sends:
+	<iq to="room@conference.localhost" id="reserve" type="set">
+		<query xmlns="http://jabber.org/protocol/muc#admin">
+			<item nick="Romeo" affiliation="owner" jid="${Romeo's JID}"/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item xmlns="http://jabber.org/protocol/muc#user" role="moderator" jid="${Romeo's full JID}" affiliation="owner">
+				<actor xmlns="http://jabber.org/protocol/muc#user" nick="Romeo"/>
+			</item>
+			<status xmlns="http://jabber.org/protocol/muc#user" code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq from="room@conference.localhost" id="reserve" type="result"/>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_subject_issue_667.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,129 @@
+# #667 MUC message with subject and body SHALL NOT be interpreted as a subject change
+
+[Client] Romeo
+	password: password
+	jid: romeo@localhost
+
+-----
+
+Romeo connects
+
+# and creates a room
+Romeo sends:
+	<presence to="issue667@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from="issue667@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<status code="201"/>
+			<item affiliation="owner" role="moderator" jid="${Romeo's full JID}"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+# the default (empty) subject
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost">
+		<subject/>
+	</message>
+
+# this should be treated as a normal message
+Romeo sends:
+	<message to="issue667@conference.localhost" type="groupchat">
+		<subject>Greetings</subject>
+		<body>Hello everyone</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>Greetings</subject>
+		<body>Hello everyone</body>
+	</message>
+
+# Resync
+Romeo sends:
+	<presence to="issue667@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+# Presences
+Romeo receives:
+	<presence from="issue667@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item affiliation="owner" role="moderator" jid="${Romeo's full JID}"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>Greetings</subject>
+		<body>Hello everyone</body>
+	</message>
+
+# the still empty subject
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost">
+		<subject/>
+	</message>
+
+# this is a subject change
+Romeo sends:
+	<message to="issue667@conference.localhost" type="groupchat">
+		<subject>Something to talk about</subject>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>Something to talk about</subject>
+	</message>
+
+# a message without <subject>
+Romeo sends:
+	<message to="issue667@conference.localhost" type="groupchat">
+		<body>Lorem ipsum dolor sit amet</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<body>Lorem ipsum dolor sit amet</body>
+	</message>
+
+# Resync
+Romeo sends:
+	<presence to="issue667@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+# Presences
+Romeo receives:
+	<presence from="issue667@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item affiliation="owner" role="moderator" jid="${Romeo's full JID}"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+# History
+# These have delay tags but we ignore those for now
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>Greetings</subject>
+		<body>Hello everyone</body>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<body>Lorem ipsum dolor sit amet</body>
+	</message>
+
+# Finally, the topic
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>Something to talk about</subject>
+	</message>
+
+Romeo disconnects
+
--- a/spec/scansion/pep_nickname.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/pep_nickname.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -1,7 +1,7 @@
 # Publishing a nickname in PEP and receiving a notification
 
 [Client] Romeo
-	jid: romeo@localhost/nJi7BeTR
+	jid: romeo@localhost
 	password: password
 
 -----
@@ -20,7 +20,7 @@
 	</iq>
 
 Romeo receives:
-	<iq id="4" to="romeo@localhost/nJi7BeTR" type="result">
+	<iq id="4" type="result">
 	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
 	    <publish node="http://jabber.org/protocol/nick">
 	      <item id="current"/>
@@ -34,12 +34,12 @@
 	</presence>
 
 Romeo receives:
-	<iq id="disco" to="romeo@localhost/nJi7BeTR" from="romeo@localhost" type="get">
+	<iq id="disco" from="romeo@localhost" type="get">
 	  <query xmlns="http://jabber.org/protocol/disco#info" node="http://code.matthewwild.co.uk/clix/#jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
 	</iq>
 
 Romeo receives:
-	<presence from="romeo@localhost/nJi7BeTR">
+	<presence>
 	  <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" node="http://code.matthewwild.co.uk/clix/" ver="jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
 	</presence>
 
@@ -55,10 +55,10 @@
 	</iq>
 
 Romeo receives:
-	<message type="headline" from="romeo@localhost" to="romeo@localhost/nJi7BeTR">
+	<message type="headline" from="romeo@localhost">
 	  <event xmlns="http://jabber.org/protocol/pubsub#event">
 	    <items node="http://jabber.org/protocol/nick">
-	      <item id="current">
+	      <item id="current" publisher="${Romeo's JID}">
 	        <nickname xmlns="http://jabber.org/protocol/nick"/>
 	      </item>
 	    </items>
--- a/spec/scansion/pep_publish_subscribe.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/pep_publish_subscribe.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -182,7 +182,7 @@
 	<iq type='result' id='fixme'/>
 
 Juliet sends:
-	<iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish></pubsub></iq>
+	<iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish></pubsub></iq>
 
 Juliet receives:
 	<iq type='result' id='7' ><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'/></publish></pubsub></iq>
@@ -197,13 +197,13 @@
 	<iq type='result' id='{scansion:any}'/>
 
 Romeo receives:
-	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
 
 Romeo sends:
 	<iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
 
 Romeo receives:
-	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
 
 Juliet disconnects
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pep_pubsub_max.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,47 @@
+# PEP max_items=max
+
+[Client] Romeo
+	jid: pep-test-maxitems@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq type="set" id="pub">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="urn:xmpp:microblog:0">
+				<item>
+					<entry xmlns='http://www.w3.org/2005/Atom'>
+						<title>Hello</title>
+					</entry>
+				</item>
+			</publish>
+			<publish-options>
+				<x xmlns="jabber:x:data" type="submit">
+					<field type="hidden" var="FORM_TYPE">
+						<value>http://jabber.org/protocol/pubsub#publish-options</value>
+					</field>
+					<field var="pubsub#persist_items">
+						<value>true</value>
+					</field>
+					<field var="pubsub#access_model">
+						<value>open</value>
+					</field>
+					<field var="pubsub#max_items">
+						<value>max</value>
+					</field>
+				</x>
+			</publish-options>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="pub">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="urn:xmpp:microblog:0">
+				<item id="{scansion:any}"/>
+			</publish>
+		</pubsub>
+	</iq>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/presence_preapproval.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,74 @@
+# server supports contact subscription pre-approval (RFC 6121 3.4)
+
+[Client] Alice
+	jid: preappove-a@localhost
+	password: password
+
+[Client] Bob
+	jid: preapprove-b@localhost
+	password: password
+
+---------
+
+Alice connects
+
+Alice sends:
+	<presence/>
+
+Alice receives:
+	<presence/>
+
+Alice sends:
+	<presence to="${Bob's JID}" type="subscribed"/>
+
+Bob connects
+
+Bob sends:
+	<iq type="get" id="roster1">
+		<query xmlns="jabber:iq:roster"/>
+	</iq>
+
+Bob receives:
+	<iq type="result" id="roster1">
+		<query xmlns="jabber:iq:roster" ver="{scansion:any}">
+		</query>
+	</iq>
+
+Bob sends:
+	<presence/>
+
+Bob receives:
+	<presence from="${Bob's full JID}"/>
+
+Bob sends:
+	<presence to="${Alice's JID}" type="subscribe" />
+
+Bob receives:
+	<iq type='set' id='{scansion:any}'>
+		<query ver='1' xmlns='jabber:iq:roster'>
+			<item jid="${Alice's JID}" subscription='none' ask='subscribe' />
+		</query>
+	</iq>
+
+
+
+Bob receives:
+	<presence from="${Alice's JID}" type="subscribed" />
+
+Bob disconnects
+
+Alice sends:
+	<iq type="get" id="roster1">
+		<query xmlns="jabber:iq:roster"/>
+	</iq>
+
+Alice receives:
+	<iq type="result" id="roster1">
+		<query xmlns="jabber:iq:roster" ver="{scansion:any}">
+			<item jid="${Bob's JID}" subscription="from" />
+		</query>
+	</iq>
+
+Alice disconnects
+
+Bob disconnects
--- a/spec/scansion/prosody.cfg.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/prosody.cfg.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,23 +1,36 @@
 --luacheck: ignore
 
+-- Mock time functions to simplify tests
+function _G.os.time()
+	return 1219439344;
+end
+package.preload["util.time"] = function ()
+	return {
+		now = function () return 1219439344.1; end;
+		monotonic = function () return 0.1; end;
+	}
+end
+
 admins = { "admin@localhost" }
 
-use_libevent = true
+network_backend = ENV_PROSODY_NETWORK_BACKEND or "epoll"
+network_settings = require"util.json".decode(ENV_PROSODY_NETWORK_SETTINGS or "{}")
 
 modules_enabled = {
 	-- Generally required
 		"roster"; -- Allow users to have a roster. Recommended ;)
 		"saslauth"; -- Authentication for clients and servers. Recommended if you want to log in.
-		"tls"; -- Add support for secure TLS on c2s/s2s connections
-		"dialback"; -- s2s dialback support
+		--"tls"; -- Add support for secure TLS on c2s/s2s connections
+		--"dialback"; -- s2s dialback support
 		"disco"; -- Service discovery
 
 	-- Not essential, but recommended
 		"carbons"; -- Keep multiple clients in sync
-		"pep"; -- Enables users to publish their mood, activity, playing music and more
+		"pep"; -- Enables users to publish their avatar, mood, activity, playing music and more
 		"private"; -- Private XML storage (for room bookmarks, etc.)
 		"blocklist"; -- Allow users to block communications with other users
-		"vcard"; -- Allow users to set vCards
+		"vcard4"; -- User profiles (stored in PEP)
+		"vcard_legacy"; -- Conversion between legacy vCard and PEP Avatar, vcard
 
 	-- Nice to have
 		"version"; -- Replies to server version requests
@@ -26,6 +39,11 @@
 		"ping"; -- Replies to XMPP pings with pongs
 		"register"; -- Allow users to register on this server using a client and change passwords
 		"mam"; -- Store messages in an archive and allow users to access it
+		--"csi_simple"; -- Simple Mobile optimizations
+
+	-- Admin interfaces
+		--"admin_adhoc"; -- Allows administration via an XMPP client that supports ad-hoc commands
+		--"admin_telnet"; -- Opens telnet console interface on localhost port 5582
 
 	-- HTTP modules
 		--"bosh"; -- Enable BOSH clients, aka "Jabber over HTTP"
@@ -35,19 +53,51 @@
 	-- Other specific functionality
 		--"limits"; -- Enable bandwidth limiting for XMPP connections
 		--"groups"; -- Shared roster support
-		--"server_contact_info"; -- Publish contact information for this service
+		"server_contact_info"; -- Publish contact information for this service
 		--"announce"; -- Send announcement to all online users
 		--"welcome"; -- Welcome users who register accounts
 		--"watchregistrations"; -- Alert admins of registrations
 		--"motd"; -- Send a message to users when they log in
 		--"legacyauth"; -- Legacy authentication. Only used by some old clients and bots.
 		--"proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
+		"lastactivity";
+		"external_services";
+
+		"tombstones";
+		"user_account_management";
 
 	-- Useful for testing
 		--"scansion_record"; -- Records things that happen in scansion test case format
 }
 
-certificate = "certs"
+contact_info = {
+	abuse = { "mailto:abuse@localhost", "xmpp:abuse@localhost" };
+	admin = { "mailto:admin@localhost", "xmpp:admin@localhost" };
+	feedback = { "http://localhost/feedback.html", "mailto:feedback@localhost", "xmpp:feedback@localhost" };
+	sales = { "xmpp:sales@localhost" };
+	security = { "xmpp:security@localhost" };
+	status = { "gopher://status.localhost" };
+	support = { "https://localhost/support.html", "xmpp:support@localhost" };
+}
+
+external_service_host = "default.example"
+external_service_port = 9876
+external_service_secret = "<secret>"
+external_services = {
+	{type = "stun"; transport = "udp"};
+	{type = "turn"; transport = "udp"; secret = true};
+	{type = "turn"; transport = "udp"; secret = "foo"};
+	{type = "ftp"; transport = "tcp"; port = 2121; username = "john"; password = "password"};
+	{type = "ftp"; transport = "tcp"; host = "ftp.example.com"; port = 21; username = "john"; password = "password"};
+}
+
+modules_disabled = {
+	"s2s";
+}
+
+-- TLS is not used during the test, set certificate dir to the config directory
+-- (spec/scansion) to silence an error from the certificate indexer
+certificates = "."
 
 allow_registration = false
 
@@ -69,15 +119,26 @@
 
 -- Logging configuration
 -- For advanced logging see https://prosody.im/doc/logging
-log = "*console"
+log = {"*console",debug = ENV_PROSODY_LOGFILE}
 
-daemonize = true
 pidfile = "prosody.pid"
 
 VirtualHost "localhost"
 
+hide_os_type = true -- absence tested for in version.scs
+
 Component "conference.localhost" "muc"
 	storage = "memory"
+	admins = { "Admin@localhost" }
+	modules_enabled = {
+		"muc_mam";
+	}
+
 
 Component "pubsub.localhost" "pubsub"
 	storage = "memory"
+	expose_publisher = true
+
+Component "upload.localhost" "http_file_share"
+http_file_share_size_limit = 10000000
+http_file_share_allowed_file_types = { "text/plain", "image/*" }
--- a/spec/scansion/pubsub_advanced.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/pubsub_advanced.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -129,7 +129,7 @@
 	<message type="headline" from="pubsub.localhost">
 		<event xmlns="http://jabber.org/protocol/pubsub#event">
 			<items node="princely_musings">
-				<item id="current">
+				<item id="current" publisher="${Romeo's JID}">
 					<entry xmlns="http://www.w3.org/2005/Atom">
 						<title>Soliloquy</title>
 						<summary>Lorem ipsum dolor sit amet</summary>
@@ -150,7 +150,11 @@
 	</iq>
 
 Juliet receives:
-	<iq type="result" id='unsub1'/>
+	<iq type="result" id='unsub1'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<subscription jid="${Juliet's full JID}" node='princely_musings' subscription='none'/>
+		</pubsub>
+	</iq>
 
 Balthasar sends:
 	<iq type="set" to="pubsub.localhost" id='del1'>
--- a/spec/scansion/pubsub_basic.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/pubsub_basic.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -32,7 +32,7 @@
 -- 			<subscribe node="princely_musings" jid="${Romeo's full JID}"/>
 -- 		</pubsub>
 -- 	</iq>
--- 
+--
 -- Juliet receives:
 -- 	<iq type="error"/>
 
@@ -67,7 +67,7 @@
 	<message type="headline" from="pubsub.localhost">
 		<event xmlns="http://jabber.org/protocol/pubsub#event">
 			<items node="princely_musings">
-				<item id="current">
+				<item id="current" publisher="${Romeo's JID}">
 					<entry xmlns="http://www.w3.org/2005/Atom">
 						<title>Soliloquy</title>
 						<summary>Lorem ipsum dolor sit amet</summary>
--- a/spec/scansion/pubsub_config.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/pubsub_config.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -48,7 +48,9 @@
 					<field var="pubsub#description" label="Description" type="text-single"/>
 					<field var="pubsub#type" label="The type of node data, usually specified by the namespace of the payload (if any)" type="text-single"/>
 					<field var="pubsub#max_items" label="Max # of items to persist" type="text-single">
-						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="pubsub:integer-or-max">
+							<range min="1" max="256"/>
+						</validate>
 						<value>1</value>
 					</field>
 					<field var="pubsub#persist_items" label="Persist items to storage" type="boolean">
@@ -84,6 +86,18 @@
 						</option>
 						<value>publishers</value>
 					</field>
+					<field type='list-single' var='pubsub#send_last_published_item'>
+						<option label='never'>
+							<value>never</value>
+						</option>
+						<option label='on_sub'>
+							<value>on_sub</value>
+						</option>
+						<option label='on_sub_and_presence'>
+							<value>on_sub_and_presence</value>
+						</option>
+						<value>on_sub_and_presence</value>
+					</field>
 					<field var="pubsub#deliver_notifications" label="Whether to deliver event notifications" type="boolean">
 						<value>1</value>
 					</field>
@@ -124,7 +138,9 @@
 					<field var="pubsub#description" type="text-single" label="Description"/>
 					<field var="pubsub#type" type="text-single" label="The type of node data, usually specified by the namespace of the payload (if any)"/>
 					<field var="pubsub#max_items" type="text-single" label="Max # of items to persist">
-						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="pubsub:integer-or-max">
+							<range min="1" max="256"/>
+						</validate>
 						<value>1</value>
 					</field>
 					<field var="pubsub#persist_items" type="boolean" label="Persist items to storage">
@@ -160,6 +176,9 @@
 						</option>
 						<value>publishers</value>
 					</field>
+					<field type='list-single' var='pubsub#send_last_published_item'>
+						<value>never</value>
+					</field>
 					<field var="pubsub#deliver_notifications" type="boolean" label="Whether to deliver event notifications">
 						<value>1</value>
 					</field>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_max_items.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,210 @@
+# Pubsub: Requesting the Most Recent Items (#1608)
+
+[Client] Alice
+	jid: admin@localhost
+	password: password
+
+---------
+
+Alice connects
+
+Alice sends:
+	<presence xmlns:stream="http://etherx.jabber.org/streams" id=":7IoqYcT3191rfk_dZGo2"/>
+
+Alice receives:
+	<presence xmlns:stream="http://etherx.jabber.org/streams" from="${Alice's full JID}" id=":7IoqYcT3191rfk_dZGo2"/>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":m0SM8Hn5JxP9BJJ_X4Mz" type="set">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <create node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06"/>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":m0SM8Hn5JxP9BJJ_X4Mz"/>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":gwZgEQmzAHcQz-FZOxi-" type="get">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+	    <configure node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06"/>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":gwZgEQmzAHcQz-FZOxi-">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+	    <configure node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <x xmlns="jabber:x:data" type="form">
+		<field var="FORM_TYPE" type="hidden">
+		  <value>http://jabber.org/protocol/pubsub#node_config</value>
+		</field>
+		<field var="pubsub#title" label="Title" type="text-single"/>
+		<field var="pubsub#description" label="Description" type="text-single"/>
+		<field var="pubsub#type" label="The type of node data, usually specified by the namespace of the payload (if any)" type="text-single"/>
+		<field var="pubsub#max_items" label="Max # of items to persist" type="text-single">
+			<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="pubsub:integer-or-max">
+				<range min="1" max="256"/>
+			</validate>
+		  <value>20</value>
+		</field>
+		<field var="pubsub#persist_items" label="Persist items to storage" type="boolean">
+		  <value>1</value>
+		</field>
+		<field var="pubsub#access_model" label="Specify the subscriber model" type="list-single">
+		  <option label="authorize">
+		    <value>authorize</value>
+		  </option>
+		  <option label="open">
+		    <value>open</value>
+		  </option>
+		  <option label="presence">
+		    <value>presence</value>
+		  </option>
+		  <option label="roster">
+		    <value>roster</value>
+		  </option>
+		  <option label="whitelist">
+		    <value>whitelist</value>
+		  </option>
+		  <value>open</value>
+		</field>
+		<field var="pubsub#publish_model" label="Specify the publisher model" type="list-single">
+		  <option label="publishers">
+		    <value>publishers</value>
+		  </option>
+		  <option label="subscribers">
+		    <value>subscribers</value>
+		  </option>
+		  <option label="open">
+		    <value>open</value>
+		  </option>
+		  <value>publishers</value>
+		</field>
+		<field type='list-single' var='pubsub#send_last_published_item'>
+			<option label='never'>
+				<value>never</value>
+			</option>
+			<option label='on_sub'>
+				<value>on_sub</value>
+			</option>
+			<option label='on_sub_and_presence'>
+				<value>on_sub_and_presence</value>
+			</option>
+			<value>never</value>
+		</field>
+		<field var="pubsub#deliver_notifications" label="Whether to deliver event notifications" type="boolean">
+		  <value>1</value>
+		</field>
+		<field var="pubsub#deliver_payloads" label="Whether to deliver payloads with event notifications" type="boolean">
+		  <value>1</value>
+		</field>
+		<field var="pubsub#notification_type" label="Specify the delivery style for notifications" type="list-single">
+		  <option label="Messages of type normal">
+		    <value>normal</value>
+		  </option>
+		  <option label="Messages of type headline">
+		    <value>headline</value>
+		  </option>
+		  <value>headline</value>
+		</field>
+		<field var="pubsub#notify_delete" label="Whether to notify subscribers when the node is deleted" type="boolean">
+		  <value>1</value>
+		</field>
+		<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
+		  <value>1</value>
+		</field>
+	      </x>
+	    </configure>
+	  </pubsub>
+	</iq>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":pfWBQ2MNIq8ieul57Qp7" type="set">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <publish node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <item id="20e9eb9e-8acb-436e-a486-40e80400faf1">
+		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">foo</foo>
+	      </item>
+	    </publish>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":pfWBQ2MNIq8ieul57Qp7">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <publish node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <item id="20e9eb9e-8acb-436e-a486-40e80400faf1"/>
+	    </publish>
+	  </pubsub>
+	</iq>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":Q5TLT6nsW0HHdkDgrPPe" type="set">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <publish node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <item id="4b94623d-1127-41c0-ac47-e283fd890557">
+		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">bar</foo>
+	      </item>
+	    </publish>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":Q5TLT6nsW0HHdkDgrPPe">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <publish node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <item id="4b94623d-1127-41c0-ac47-e283fd890557"/>
+	    </publish>
+	  </pubsub>
+	</iq>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":3nvB2E20p1iuM6lOPaP6" type="get">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<items node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06" max_items="1"/>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":3nvB2E20p1iuM6lOPaP6">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <items node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <item publisher="${Alice's JID}" xmlns="http://jabber.org/protocol/pubsub" id="4b94623d-1127-41c0-ac47-e283fd890557">
+		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">bar</foo>
+	      </item>
+	    </items>
+	  </pubsub>
+	</iq>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":XQdyK54iyOKiJvUoX9t_" type="get">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <items node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06"/>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":XQdyK54iyOKiJvUoX9t_">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <items node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06">
+	      <item xmlns="http://jabber.org/protocol/pubsub" publisher="${Alice's JID}" id="20e9eb9e-8acb-436e-a486-40e80400faf1">
+		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">foo</foo>
+	      </item>
+	      <item xmlns="http://jabber.org/protocol/pubsub" publisher="${Alice's JID}" id="4b94623d-1127-41c0-ac47-e283fd890557">
+		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">bar</foo>
+	      </item>
+	    </items>
+	  </pubsub>
+	</iq>
+
+Alice sends:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="pubsub.localhost" id=":ySGQOz5tnyWT82idwJZP" type="set">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+	    <delete node="5549ea47-ea53-4cc1-9e7c-37842fe4bc06"/>
+	  </pubsub>
+	</iq>
+
+Alice receives:
+	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":ySGQOz5tnyWT82idwJZP"/>
+
--- a/spec/scansion/pubsub_multi_items.scs	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/scansion/pubsub_multi_items.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -43,11 +43,13 @@
 		<field var="pubsub#description" label="Description" type="text-single"/>
 		<field var="pubsub#type" label="The type of node data, usually specified by the namespace of the payload (if any)" type="text-single"/>
 		<field var="pubsub#max_items" label="Max # of items to persist" type="text-single">
-		  <validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+			<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="pubsub:integer-or-max">
+				<range min="1" max="256"/>
+			</validate>
 		  <value>20</value>
 		</field>
 		<field var="pubsub#persist_items" label="Persist items to storage" type="boolean">
-		  <value>0</value>
+		  <value>1</value>
 		</field>
 		<field var="pubsub#access_model" label="Specify the subscriber model" type="list-single">
 		  <option label="authorize">
@@ -79,6 +81,18 @@
 		  </option>
 		  <value>publishers</value>
 		</field>
+		<field type='list-single' var='pubsub#send_last_published_item'>
+			<option label='never'>
+				<value>never</value>
+			</option>
+			<option label='on_sub'>
+				<value>on_sub</value>
+			</option>
+			<option label='on_sub_and_presence'>
+				<value>on_sub_and_presence</value>
+			</option>
+			<value>never</value>
+		</field>
 		<field var="pubsub#deliver_notifications" label="Whether to deliver event notifications" type="boolean">
 		  <value>1</value>
 		</field>
@@ -159,10 +173,10 @@
 	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":3nvB2E20p1iuM6lOPaP6">
 	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
 	    <items node="e96caf12-264f-4e5a-988e-00ae191771b6">
-	      <item xmlns="http://jabber.org/protocol/pubsub" id="20e9eb9e-8acb-436e-a486-40e80400faf1">
+	      <item publisher="${Alice's JID}" xmlns="http://jabber.org/protocol/pubsub" id="20e9eb9e-8acb-436e-a486-40e80400faf1">
 		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">foo</foo>
 	      </item>
-	      <item xmlns="http://jabber.org/protocol/pubsub" id="4b94623d-1127-41c0-ac47-e283fd890557">
+	      <item publisher="${Alice's JID}" xmlns="http://jabber.org/protocol/pubsub" id="4b94623d-1127-41c0-ac47-e283fd890557">
 		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">bar</foo>
 	      </item>
 	    </items>
@@ -180,10 +194,10 @@
 	<iq xmlns:stream="http://etherx.jabber.org/streams" to="${Alice's full JID}" from="pubsub.localhost" type="result" id=":XQdyK54iyOKiJvUoX9t_">
 	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
 	    <items node="e96caf12-264f-4e5a-988e-00ae191771b6">
-	      <item xmlns="http://jabber.org/protocol/pubsub" id="20e9eb9e-8acb-436e-a486-40e80400faf1">
+	      <item xmlns="http://jabber.org/protocol/pubsub" publisher="${Alice's JID}" id="20e9eb9e-8acb-436e-a486-40e80400faf1">
 		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">foo</foo>
 	      </item>
-	      <item xmlns="http://jabber.org/protocol/pubsub" id="4b94623d-1127-41c0-ac47-e283fd890557">
+	      <item xmlns="http://jabber.org/protocol/pubsub" publisher="${Alice's JID}" id="4b94623d-1127-41c0-ac47-e283fd890557">
 		<foo xmlns="https://zombofant.net/xmlns/aioxmpp#test">bar</foo>
 	      </item>
 	    </items>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_preconditions.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,253 @@
+# Pubsub preconditions are enforced
+
+[Client] Romeo
+	password: password
+	jid: jqpcrbq2@localhost
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="http://jabber.org/protocol/tune">
+				<item id="current">
+					<tune xmlns="http://jabber.org/protocol/tune"/>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="http://jabber.org/protocol/tune">
+				<item id="current"/>
+			</publish>
+		</pubsub>
+	</iq>
+
+Romeo sends:
+	<iq id="52d74a36-afb0-4028-87ed-b25b988b049e" type="get">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="http://jabber.org/protocol/tune"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="52d74a36-afb0-4028-87ed-b25b988b049e" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="http://jabber.org/protocol/tune">
+				<x xmlns="jabber:x:data" type="form">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#title" label="Title" type="text-single"/>
+					<field var="pubsub#description" label="Description" type="text-single"/>
+					<field var="pubsub#type" label="The type of node data, usually specified by the namespace of the payload (if any)" type="text-single"/>
+					<field var="pubsub#max_items" label="Max # of items to persist" type="text-single">
+						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="pubsub:integer-or-max">
+							<range min="1" max="256"/>
+						</validate>
+						<value>1</value>
+					</field>
+					<field var="pubsub#persist_items" label="Persist items to storage" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#access_model" label="Specify the subscriber model" type="list-single">
+						<option label="authorize">
+							<value>authorize</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<option label="presence">
+							<value>presence</value>
+						</option>
+						<option label="roster">
+							<value>roster</value>
+						</option>
+						<option label="whitelist">
+							<value>whitelist</value>
+						</option>
+						<value>presence</value>
+					</field>
+					<field var="pubsub#publish_model" label="Specify the publisher model" type="list-single">
+						<option label="publishers">
+							<value>publishers</value>
+						</option>
+						<option label="subscribers">
+							<value>subscribers</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<value>publishers</value>
+					</field>
+					<field type='list-single' var='pubsub#send_last_published_item'>
+						<option label='never'>
+							<value>never</value>
+						</option>
+						<option label='on_sub'>
+							<value>on_sub</value>
+						</option>
+						<option label='on_sub_and_presence'>
+							<value>on_sub_and_presence</value>
+						</option>
+						<value>on_sub_and_presence</value>
+					</field>
+					<field var="pubsub#deliver_notifications" label="Whether to deliver event notifications" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#deliver_payloads" label="Whether to deliver payloads with event notifications" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notification_type" label="Specify the delivery style for notifications" type="list-single">
+						<option label="Messages of type normal">
+							<value>normal</value>
+						</option>
+						<option label="Messages of type headline">
+							<value>headline</value>
+						</option>
+						<value>headline</value>
+					</field>
+					<field var="pubsub#notify_delete" label="Whether to notify subscribers when the node is deleted" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
+						<value>1</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo sends:
+	<iq id="a73aac09-74be-4ee2-97e5-571bbdbcd956" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="http://jabber.org/protocol/tune">
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#title" type="text-single" label="Title">
+						<value>Nice tunes</value>
+					</field>
+					<field var="pubsub#description" type="text-single" label="Description"/>
+					<field var="pubsub#type" type="text-single" label="The type of node data, usually specified by the namespace of the payload (if any)"/>
+					<field var="pubsub#max_items" type="text-single" label="Max # of items to persist">
+						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="pubsub:integer-or-max">
+							<range min="1" max="256"/>
+						</validate>
+						<value>1</value>
+					</field>
+					<field var="pubsub#persist_items" type="boolean" label="Persist items to storage">
+						<value>1</value>
+					</field>
+					<field var="pubsub#access_model" type="list-single" label="Specify the subscriber model">
+						<option label="authorize">
+							<value>authorize</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<option label="presence">
+							<value>presence</value>
+						</option>
+						<option label="roster">
+							<value>roster</value>
+						</option>
+						<option label="whitelist">
+							<value>whitelist</value>
+						</option>
+						<value>presence</value>
+					</field>
+					<field var="pubsub#publish_model" type="list-single" label="Specify the publisher model">
+						<option label="publishers">
+							<value>publishers</value>
+						</option>
+						<option label="subscribers">
+							<value>subscribers</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<value>publishers</value>
+					</field>
+					<field type='list-single' var='pubsub#send_last_published_item'>
+						<value>never</value>
+					</field>
+					<field var="pubsub#deliver_notifications" type="boolean" label="Whether to deliver event notifications">
+						<value>1</value>
+					</field>
+					<field var="pubsub#deliver_payloads" type="boolean" label="Whether to deliver payloads with event notifications">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notification_type" type="list-single" label="Specify the delivery style for notifications">
+						<option label="Messages of type normal">
+							<value>normal</value>
+						</option>
+						<option label="Messages of type headline">
+							<value>headline</value>
+						</option>
+						<value>headline</value>
+					</field>
+					<field var="pubsub#notify_delete" type="boolean" label="Whether to notify subscribers when the node is deleted">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notify_retract" type="boolean" label="Whether to notify subscribers when items are removed from the node">
+						<value>1</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="a73aac09-74be-4ee2-97e5-571bbdbcd956" type="result"/>
+
+Romeo sends:
+	<iq id="ab0e92d2-c06b-4987-9d45-f9f9e7721709" type="get">
+		<query xmlns="http://jabber.org/protocol/disco#items"/>
+	</iq>
+
+Romeo receives:
+	<iq id="ab0e92d2-c06b-4987-9d45-f9f9e7721709" type="result">
+		<query xmlns="http://jabber.org/protocol/disco#items">
+			<item name="Nice tunes" node="http://jabber.org/protocol/tune" jid="${Romeo's JID}"/>
+		</query>
+	</iq>
+
+Romeo sends:
+	<iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="http://jabber.org/protocol/tune">
+				<item id="current">
+					<tune xmlns="http://jabber.org/protocol/tune"/>
+				</item>
+			</publish>
+			<publish-options>
+				<x xmlns="jabber:x:data">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#publish-options</value>
+					</field>
+					<field var="pubsub#access_model">
+						<value>whitelist</value>
+					</field>
+				</x>
+			</publish-options>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type='error' id='67eb1f47-1e69-4cb3-91e2-4d5943e72d4c'>
+		<error type='cancel'>
+			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Field does not match: access_model</text>
+			<precondition-not-met xmlns='http://jabber.org/protocol/pubsub#errors'/>
+		</error>
+	</iq>
+
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_resend_on_sub.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,152 @@
+# Pubsub: Send last item on subscribe #1436
+
+[Client] Romeo
+	jid: admin@localhost
+	password: password
+
+// admin@localhost is assumed to have node creation privileges
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='create1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='create1'/>
+
+Romeo sends:
+	<iq to="pubsub.localhost" id="config-never" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="princely_musings">
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field type='list-single' var='pubsub#send_last_published_item'>
+						<value>never</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq from="pubsub.localhost" id="config-never" type="result"/>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='pub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="princely_musings">
+				<item id="current">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Soliloquy</title>
+						<summary>Lorem ipsum dolor sit amet</summary>
+					</entry>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='pub1'/>
+
+Juliet connects
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub1'/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='unsub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<unsubscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='unsub1'/>
+
+Romeo sends:
+	<iq to="pubsub.localhost" id="config-on_sub" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="princely_musings">
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field type='list-single' var='pubsub#send_last_published_item'>
+						<value>on_sub</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq from="pubsub.localhost" id="config-on_sub" type="result"/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub2'/>
+
+Juliet receives:
+	<message type="headline" from="pubsub.localhost">
+		<event xmlns="http://jabber.org/protocol/pubsub#event">
+			<items node="princely_musings">
+				<item id="current" publisher="${Romeo's JID}">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Soliloquy</title>
+						<summary>Lorem ipsum dolor sit amet</summary>
+					</entry>
+				</item>
+			</items>
+		</event>
+	</message>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='unsub2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<unsubscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='unsub2'/>
+
+Juliet disconnects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='del1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<delete node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='del1'/>
+
+Romeo disconnects
+
+// vim: syntax=xml:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/server_contact_info.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,81 @@
+# XEP-0157: Contact Addresses for XMPP Services
+# mod_server_contact_info
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq type='get' id='lx2' to='localhost'>
+		<query xmlns='http://jabber.org/protocol/disco#info'/>
+	</iq>
+
+# Ignore other disco#info features, identities etc
+
+Romeo receives:
+	<iq from='localhost' id='lx2' type='result'>
+		<query xmlns='http://jabber.org/protocol/disco#info' scansion:strict='false'>
+			<x xmlns='jabber:x:data' type='result'>
+				<field type='hidden' var='FORM_TYPE'>
+					<value>http://jabber.org/network/serverinfo</value>
+				</field>
+				<field type='list-multi' var='abuse-addresses'>
+					<value>mailto:abuse@localhost</value>
+					<value>xmpp:abuse@localhost</value>
+				</field>
+				<field type='list-multi' var='admin-addresses'>
+					<value>mailto:admin@localhost</value>
+					<value>xmpp:admin@localhost</value>
+				</field>
+				<field type='list-multi' var='feedback-addresses'>
+					<value>http://localhost/feedback.html</value>
+					<value>mailto:feedback@localhost</value>
+					<value>xmpp:feedback@localhost</value>
+				</field>
+				<field type='list-multi' var='sales-addresses'>
+					<value>xmpp:sales@localhost</value>
+				</field>
+				<field type='list-multi' var='security-addresses'>
+					<value>xmpp:security@localhost</value>
+				</field>
+				<field type='list-multi' var='status-addresses'>
+					<value>gopher://status.localhost</value>
+				</field>
+				<field type='list-multi' var='support-addresses'>
+					<value>https://localhost/support.html</value>
+					<value>xmpp:support@localhost</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+
+Romeo sends:
+	<iq type='get' id='lx2' to='conference.localhost'>
+		<query xmlns='http://jabber.org/protocol/disco#info'/>
+	</iq>
+
+	<iq from='localhost' id='lx2' type='result'>
+		<query xmlns='http://jabber.org/protocol/disco#info' scansion:strict='false'>
+			<x xmlns='jabber:x:data' type='result'>
+				<field type='hidden' var='FORM_TYPE'>
+					<value>http://jabber.org/network/serverinfo</value>
+				</field>
+				<field type='list-multi' var='abuse-addresses'/>
+				<field type='list-multi' var='admin-addresses'>
+					<value>xmpp:admin@localhost</value>
+				</field>
+				<field type='list-multi' var='feedback-addresses'/>
+				<field type='list-multi' var='sales-addresses'/>
+				<field type='list-multi' var='security-addresses'/>
+				<field type='list-multi' var='status-addresses'/>
+				<field type='list-multi' var='support-addresses'/>
+			</x>
+		</query>
+	</iq>
+
+Romeo disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/tombstones.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,38 @@
+# Tombstones
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet-tombstones@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Juliet connects
+
+Juliet sends:
+	<iq type="set" id="bye">
+		<query xmlns="jabber:iq:register">
+			<remove/>
+		</query>
+	</iq>
+
+# Scansion gets disconnected right after this with a stream error makes
+# scansion itself abort, so we preemptively disconnect to avoid that
+# Juliet receives:
+#	<iq type="result" id="bye"/>
+
+Juliet disconnects
+
+Romeo sends:
+	<presence type="probe" to="${Juliet's JID}"/>
+
+Romeo receives:
+	<presence type="error" from="${Juliet's JID}"/>
+
+Romeo receives:
+	<presence type="unsubscribed" from="${Juliet's JID}"/>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/uptime.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,21 @@
+# XEP-0012: Last Activity / mod_uptime
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq id='a' type='get' to='localhost'>
+		<query xmlns='jabber:iq:last'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='a' from='localhost'>
+		<query xmlns='jabber:iq:last' seconds='0'/>
+	</iq>
+
+Romeo disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/version.scs	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,27 @@
+# XEP-0092: Software Version / mod_version
+
+[Client] Romeo
+	password: password
+	jid: romeo@localhost/dfaZpuxV
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq id='lx2' to='localhost' type='get'>
+		<query xmlns='jabber:iq:version'/>
+	</iq>
+
+# Version string would vary so we can't do an exact match atm
+# Inclusion of <os/> is disabled in the config, it should be absent
+Romeo receives:
+	<iq id='lx2' from='localhost' type='result'>
+		<query xmlns='jabber:iq:version' scansion:strict='true'>
+			<name>Prosody</name>
+			<version scansion:strict='false'/>
+		</query>
+	</iq>
+
+
+Romeo disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_argparse_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,53 @@
+describe("parse", function()
+	local parse
+	setup(function() parse = require"util.argparse".parse; end);
+
+	it("works", function()
+		-- basic smoke test
+		local opts = parse({ "--help" });
+		assert.same({ help = true }, opts);
+	end);
+
+	it("returns if no args", function() assert.same({}, parse({})); end);
+
+	it("supports boolean flags", function()
+		local opts, err = parse({ "--foo"; "--no-bar" });
+		assert.falsy(err);
+		assert.same({ foo = true; bar = false }, opts);
+	end);
+
+	it("consumes input until the first argument", function()
+		local arg = { "--foo"; "bar"; "--baz" };
+		local opts, err = parse(arg);
+		assert.falsy(err);
+		assert.same({ foo = true, "bar", "--baz" }, opts);
+		assert.same({ "bar"; "--baz" }, arg);
+	end);
+
+	it("expands short options", function()
+		local opts, err = parse({ "--foo"; "-b" }, { short_params = { b = "bar" } });
+		assert.falsy(err);
+		assert.same({ foo = true; bar = true }, opts);
+	end);
+
+	it("supports value arguments", function()
+		local opts, err = parse({ "--foo"; "bar"; "--baz=moo" }, { value_params = { foo = true; bar = true } });
+		assert.falsy(err);
+		assert.same({ foo = "bar"; baz = "moo" }, opts);
+	end);
+
+	it("demands values for value params", function()
+		local opts, err, where = parse({ "--foo" }, { value_params = { foo = true } });
+		assert.falsy(opts);
+		assert.equal("missing-value", err);
+		assert.equal("--foo", where);
+	end);
+
+	it("reports where the problem is", function()
+		local opts, err, where = parse({ "-h" });
+		assert.falsy(opts);
+		assert.equal("param-not-found", err);
+		assert.equal("-h", where, "returned where");
+	end);
+
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_array_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,174 @@
+local array = require "util.array";
+describe("util.array", function ()
+	describe("creation", function ()
+		describe("from table", function ()
+			it("works", function ()
+				local a = array({"a", "b", "c"});
+				assert.same({"a", "b", "c"}, a);
+			end);
+		end);
+
+		describe("from iterator", function ()
+			it("works", function ()
+				-- collects the first value, ie the keys
+				local a = array(ipairs({true, true, true}));
+				assert.same({1, 2, 3}, a);
+			end);
+		end);
+
+		describe("collect", function ()
+			it("works", function ()
+				-- collects the first value, ie the keys
+				local a = array.collect(ipairs({true, true, true}));
+				assert.same({1, 2, 3}, a);
+			end);
+		end);
+
+	end);
+
+	describe("metatable", function ()
+		describe("operator", function ()
+			describe("addition", function ()
+				it("works", function ()
+					local a = array({ "a", "b" });
+					local b = array({ "c", "d" });
+					assert.same({"a", "b", "c", "d"}, a + b);
+				end);
+			end);
+
+			describe("equality", function ()
+				it("works", function ()
+					local a1 = array({ "a", "b" });
+					local a2 = array({ "a", "b" });
+					local b = array({ "c", "d" });
+					assert.truthy(a1 == a2);
+					assert.falsy(a1 == b);
+					assert.falsy(a1 == { "a", "b" }, "Behavior of metatables changed in Lua 5.3");
+				end);
+			end);
+
+			describe("division", function ()
+				it("works", function ()
+					local a = array({ "a", "b", "c" });
+					local b = a / function (i) if i ~= "b" then return i .. "x" end end;
+					assert.same({ "ax", "cx" }, b);
+				end);
+			end);
+
+		end);
+	end);
+
+	describe("methods", function ()
+		describe("map", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				local b = a:map(string.upper);
+				assert.same({ "A", "B", "C" }, b);
+			end);
+		end);
+
+		describe("filter", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				a:filter(function (i) return i ~= "b" end);
+				assert.same({ "a", "c" }, a);
+			end);
+		end);
+
+		describe("sort", function ()
+			it("works", function ()
+				local a = array({ 5, 4, 3, 1, 2, });
+				a:sort();
+				assert.same({ 1, 2, 3, 4, 5, }, a);
+			end);
+		end);
+
+		describe("unique", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c", "c", "a", "b" });
+				a:unique();
+				assert.same({ "a", "b", "c" }, a);
+			end);
+		end);
+
+		describe("pluck", function ()
+			it("works", function ()
+				local a = array({ { a = 1, b = -1 }, { a = 2, b = -2 }, });
+				a:pluck("a");
+				assert.same({ 1, 2 }, a);
+			end);
+		end);
+
+
+		describe("reverse", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				a:reverse();
+				assert.same({ "c", "b", "a" }, a);
+			end);
+		end);
+
+		-- TODO :shuffle
+
+		describe("append", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				a:append(array({ "d", "e", }));
+				assert.same({ "a", "b", "c", "d", "e" }, a);
+			end);
+		end);
+
+		describe("push", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				a:push("d"):push("e");
+				assert.same({ "a", "b", "c", "d", "e" }, a);
+			end);
+		end);
+
+		describe("pop", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				assert.equal("c", a:pop());
+				assert.same({ "a", "b", }, a);
+			end);
+		end);
+
+		describe("concat", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				assert.equal("a,b,c", a:concat(","));
+			end);
+		end);
+
+		describe("length", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				assert.equal(3, a:length());
+			end);
+		end);
+
+		describe("slice", function ()
+			it("works", function ()
+				local a = array({ "a", "b", "c" });
+				assert.equal(array.slice(a, 1, 2), array{ "a", "b" });
+				assert.equal(array.slice(a, 1, 3), array{ "a", "b", "c" });
+				assert.equal(array.slice(a, 2, 3), array{ "b", "c" });
+				assert.equal(array.slice(a, 2), array{ "b", "c" });
+				assert.equal(array.slice(a, -4), array{ "a", "b", "c" });
+				assert.equal(array.slice(a, -3), array{ "a", "b", "c" });
+				assert.equal(array.slice(a, -2), array{ "b", "c" });
+				assert.equal(array.slice(a, -1), array{ "c" });
+			end);
+
+			it("can mutate", function ()
+				local a = array({ "a", "b", "c" });
+				assert.equal(a:slice(-1), array{"c"});
+				assert.equal(a, array{"c"});
+			end);
+		end);
+	end);
+
+	-- TODO The various array.foo(array ina, array outa) functions
+end);
+
--- a/spec/util_async_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_async_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,4 +1,5 @@
 local async = require "util.async";
+local match = require "luassert.match";
 
 describe("util.async", function()
 	local debug = false;
@@ -544,6 +545,8 @@
 			assert.equal(r1.state, "ready");
 		end);
 
+		-- luacheck: ignore 211/rf
+		-- FIXME what's rf?
 		it("should support multiple done() calls", function ()
 			local processed_item;
 			local wait, done;
@@ -613,4 +616,104 @@
 			assert.spy(r.watchers.error).was_not.called();
 		end);
 	end);
+
+	describe("#sleep()", function ()
+		after_each(function ()
+			-- Restore to default
+			async.set_schedule_function(nil);
+		end);
+
+		it("should fail if no scheduler configured", function ()
+			local r = new(function ()
+				async.sleep(5);
+			end);
+			r:run(true);
+			assert.spy(r.watchers.error).was.called();
+
+			-- Set dummy scheduler
+			async.set_schedule_function(function () end);
+
+			local r2 = new(function ()
+				async.sleep(5);
+			end);
+			r2:run(true);
+			assert.spy(r2.watchers.error).was_not.called();
+		end);
+		it("should work", function ()
+			local queue = {};
+			local add_task = spy.new(function (t, f)
+				table.insert(queue, { t, f });
+			end);
+			async.set_schedule_function(add_task);
+
+			local processed_item;
+			local r = new(function (item)
+				async.sleep(5);
+				processed_item = item;
+			end);
+			r:run("test");
+
+			-- Nothing happened, because the runner is sleeping
+			assert.is_nil(processed_item);
+			assert.equal(r.state, "waiting");
+			assert.spy(add_task).was_called(1);
+			assert.spy(add_task).was_called_with(match.is_number(), match.is_function());
+			assert.spy(r.watchers.waiting).was.called();
+			assert.spy(r.watchers.ready).was_not.called();
+
+			-- Pretend the timer has triggered, call the handler
+			queue[1][2]();
+
+			assert.equal(processed_item, "test");
+			assert.equal(r.state, "ready");
+
+			assert.spy(r.watchers.ready).was.called();
+		end);
+	end);
+
+	describe("#set_nexttick()", function ()
+		after_each(function ()
+			-- Restore to default
+			async.set_nexttick(nil);
+		end);
+		it("should work", function ()
+			local queue = {};
+			local nexttick = spy.new(function (f)
+				assert.is_function(f);
+				table.insert(queue, f);
+			end);
+			async.set_nexttick(nexttick);
+
+			local processed_item;
+			local wait, done;
+			local r = new(function (item)
+				wait, done = async.waiter();
+				wait();
+				processed_item = item;
+			end);
+			r:run("test");
+
+			-- Nothing happened, because the runner is waiting
+			assert.is_nil(processed_item);
+			assert.equal(r.state, "waiting");
+			assert.spy(nexttick).was_called(0);
+			assert.spy(r.watchers.waiting).was.called();
+			assert.spy(r.watchers.ready).was_not.called();
+
+			-- Mark the runner as ready, it should be scheduled for
+			-- the next tick
+			done();
+
+			assert.spy(nexttick).was_called(1);
+			assert.spy(nexttick).was_called_with(match.is_function());
+			assert.equal(1, #queue);
+
+			-- Pretend it's the next tick - call the pending function
+			queue[1]();
+
+			assert.equal(processed_item, "test");
+			assert.equal(r.state, "ready");
+			assert.spy(r.watchers.ready).was.called();
+		end);
+	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_bitcompat_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,27 @@
+describe("util.bitcompat", function ()
+	-- bitcompat will pass through to an appropriate implementation. Our
+	-- goal here is to check that whatever implementation is in use passes
+	-- these basic sanity checks.
+
+	local bit = require "util.bitcompat";
+
+	it("bor works", function ()
+		assert.equal(0xF0FF, bit.bor(0xF000, 0x00F0, 0x000F));
+	end);
+
+	it("band works", function ()
+		assert.equal(0x0F, bit.band(0xFF, 0x1F, 0x0F));
+	end);
+
+	it("bxor works", function ()
+		assert.equal(0x13, bit.bxor(0x10, 0x0F, 0x0C));
+	end);
+
+	it("rshift works", function ()
+		assert.equal(0x0F, bit.rshift(0xFF, 4));
+	end);
+
+	it("lshift works", function ()
+		assert.equal(0xFF00, bit.lshift(0xFF, 8));
+	end);
+end);
--- a/spec/util_cache_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_cache_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -311,6 +311,30 @@
 
 			expect_kv("e", 5, c5:head());
 			expect_kv("c", 3, c5:tail());
+
+		end);
+
+		(_VERSION=="Lua 5.1" and pending or it)(":table works", function ()
+			local t = cache.new(3):table();
+			assert.is.table(t);
+			t["a"] = "1";
+			assert.are.equal(t["a"], "1");
+			t["b"] = "2";
+			assert.are.equal(t["b"], "2");
+			t["c"] = "3";
+			assert.are.equal(t["c"], "3");
+			t["d"] = "4";
+			assert.are.equal(t["d"], "4");
+			assert.are.equal(t["a"], nil);
+
+				local i = spy.new(function () end);
+				for k, v in pairs(t) do
+					i(k,v)
+				end
+				assert.spy(i).was_called();
+				assert.spy(i).was_called_with("b", "2");
+				assert.spy(i).was_called_with("c", "3");
+				assert.spy(i).was_called_with("d", "4");
 		end);
 	end);
 end);
--- a/spec/util_dataforms_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_dataforms_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -106,11 +106,26 @@
 				name = "text-single-field",
 				value = "text-single-value",
 			},
+			{
+				-- XEP-0221
+				-- TODO Validate the XML produced by this.
+				type = "text-single",
+				label = "text-single-with-media-label",
+				name = "text-single-with-media-field",
+				media = {
+					height = 24,
+					width = 32,
+					{
+						type = "image/png",
+						uri = "data:",
+					},
+				},
+			},
 		});
 		xform = some_form:form();
 	end);
 
-	it("works", function ()
+	it("XML serialization looks like it should", function ()
 		assert.truthy(xform);
 		assert.truthy(st.is_stanza(xform));
 		assert.equal("x", xform.name);
@@ -316,7 +331,7 @@
 	end);
 
 	describe(":data", function ()
-		it("works", function ()
+		it("returns something", function ()
 			assert.truthy(some_form:data(xform));
 		end);
 	end);
@@ -402,25 +417,95 @@
 		end);
 	end);
 
-	describe("validation", function ()
-		local f = dataforms.new {
-			{
-				name = "number",
-				type = "text-single",
-				datatype = "xs:integer",
-			},
-		};
+	describe("number handling", function()
+		it("handles numbers as booleans", function()
+			local f = dataforms.new { { name = "boolean"; type = "boolean" } };
+			local x = f:form({ boolean = 0 });
+			assert.equal("0", x:find "field/value#");
+			x = f:form({ boolean = 1 });
+			assert.equal("1", x:find "field/value#");
+		end);
+	end)
+
+	describe("datatype validation", function ()
+		describe("integer", function ()
+
+			local f = dataforms.new {
+				{
+					name = "number",
+					type = "text-single",
+					datatype = "xs:integer",
+					range_min = -10,
+					range_max = 10,
+				},
+			};
+
+			it("roundtrip works", function ()
+				local d = f:data(f:form({number = 1}));
+				assert.equal(1, d.number);
+			end);
+
+			it("error handling works", function ()
+				local d,e = f:data(f:form({number = "nan"}));
+				assert.not_equal(1, d.number);
+				assert.table(e);
+				assert.string(e.number);
+			end);
+
+			it("bounds-checking work works", function ()
+				local d,e = f:data(f:form({number = 100}));
+				assert.not_equal(100, d.number);
+				assert.table(e);
+				assert.string(e.number);
+			end);
 
-		it("works", function ()
-			local d = f:data(f:form({number = 1}));
-			assert.equal(1, d.number);
-		end);
+			it("serializes larger ints okay", function ()
+				local x = f:form{number=1125899906842624}
+				assert.equal("1125899906842624", x:find("field/value#"))
+			end);
+
+		end)
+
+		describe("datetime", function ()
+			local f = dataforms.new { { name = "when"; type = "text-single"; datatype = "xs:dateTime" } }
+
+			it("works", function ()
+				local x = f:form({ when = 1219439340 });
+				assert.equal("2008-08-22T21:09:00Z", x:find("field/value#"))
+				local d, e = f:data(x);
+				assert.is_nil(e);
+				assert.same({ when = 1219439340 }, d);
+			end);
+
+		end)
 
-		it("works", function ()
-			local d,e = f:data(f:form({number = "nan"}));
-			assert.not_equal(1, d.number);
-			assert.table(e);
-			assert.string(e.number);
+	end);
+	describe("media element", function ()
+		it("produced media element correctly", function ()
+			local f;
+			for field in xform:childtags("field") do
+				if field.attr.var == "text-single-with-media-field" then
+					f = field;
+					break;
+				end
+			end
+
+			assert.truthy(st.is_stanza(f));
+			assert.equal("text-single-with-media-field", f.attr.var);
+			assert.equal("text-single", f.attr.type);
+			assert.equal("text-single-with-media-label", f.attr.label);
+			assert.equal(0, iter.count(f:childtags("value")));
+
+			local m = f:get_child("media", "urn:xmpp:media-element");
+			assert.truthy(st.is_stanza(m));
+			assert.equal("24", m.attr.height);
+			assert.equal("32", m.attr.width);
+			assert.equal(1, iter.count(m:childtags("uri")));
+
+			local u = m:get_child("uri");
+			assert.truthy(st.is_stanza(u));
+			assert.equal("image/png", u.attr.type);
+			assert.equal("data:", u:get_text());
 		end);
 	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_datamanager_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,76 @@
+describe("util.datamanager", function()
+	local dm;
+	setup(function()
+		dm = require "util.datamanager";
+		dm.set_data_path("./data");
+	end);
+
+	describe("keyvalue", function()
+		local data = {hello = "world"};
+
+		do
+			local ok, err = dm.store("keyval-user", "datamanager.test", "testdata", data);
+			assert.truthy(ok, err);
+		end
+
+		do
+			local read, err = dm.load("keyval-user", "datamanager.test", "testdata")
+			assert.same(data, read, err);
+		end
+
+		do
+			local ok, err = dm.store("keyval-user", "datamanager.test", "testdata", nil);
+			assert.truthy(ok, err);
+		end
+
+		do
+			local read, err = dm.load("keyval-user", "datamanager.test", "testdata")
+			assert.is_nil(read, err);
+		end
+	end)
+
+	describe("lists", function()
+		do
+			local ok, err = dm.list_store("list-user", "datamanager.test", "testdata", {});
+			assert.truthy(ok, err);
+		end
+
+		do
+			local nothing, err = dm.list_load("list-user", "datamanager.test", "testdata");
+			assert.is_nil(nothing, err);
+			assert.is_nil(err);
+		end
+
+		do
+			local ok, err = dm.list_append("list-user", "datamanager.test", "testdata", {id = 1});
+			assert.truthy(ok, err);
+		end
+
+		do
+			local ok, err = dm.list_append("list-user", "datamanager.test", "testdata", {id = 2});
+			assert.truthy(ok, err);
+		end
+
+		do
+			local ok, err = dm.list_append("list-user", "datamanager.test", "testdata", {id = 3});
+			assert.truthy(ok, err);
+		end
+
+		do
+			local list, err = dm.list_load("list-user", "datamanager.test", "testdata");
+			assert.same(list, {{id = 1}; {id = 2}; {id = 3}}, err);
+		end
+
+		do
+			local ok, err = dm.list_store("list-user", "datamanager.test", "testdata", {});
+			assert.truthy(ok, err);
+		end
+
+		do
+			local nothing, err = dm.list_load("list-user", "datamanager.test", "testdata");
+			assert.is_nil(nothing, err);
+			assert.is_nil(err);
+		end
+
+	end)
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_datamapper_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,243 @@
+local st
+local xml
+local map
+
+setup(function()
+	st = require "util.stanza";
+	xml = require "util.xml";
+	map = require "util.datamapper";
+end);
+
+describe("util.datamapper", function()
+
+	local s, x, d
+	local disco, disco_info, disco_schema
+	setup(function()
+
+		-- a convenience function for simple attributes, there's a few of them
+		local function attr() return {["$ref"]="#/$defs/attr"} end
+		s = {
+			["$defs"] = { attr = { type = "string"; xml = { attribute = true } } };
+			type = "object";
+			xml = {name = "message"; namespace = "jabber:client"};
+			properties = {
+				to = attr();
+				from = attr();
+				type = attr();
+				id = attr();
+				body = true; -- should be assumed to be a string
+				lang = {type = "string"; xml = {attribute = true; prefix = "xml"}};
+				delay = {
+					type = "object";
+					xml = {namespace = "urn:xmpp:delay"; name = "delay"};
+					properties = {stamp = attr(); from = attr(); reason = {type = "string"; xml = {text = true}}};
+				};
+				state = {
+					type = "string";
+					enum = {
+						"active",
+						"inactive",
+						"gone",
+						"composing",
+						"paused",
+					};
+					xml = {x_name_is_value = true; namespace = "http://jabber.org/protocol/chatstates"};
+				};
+				fallback = {
+					type = "boolean";
+					xml = {x_name_is_value = true; name = "fallback"; namespace = "urn:xmpp:fallback:0"};
+				};
+				origin_id = {
+					type = "string";
+					xml = {name = "origin-id"; namespace = "urn:xmpp:sid:0"; x_single_attribute = "id"};
+				};
+				react = {
+					type = "object";
+					xml = {namespace = "urn:xmpp:reactions:0"; name = "reactions"};
+					properties = {
+						to = {type = "string"; xml = {attribute = true; name = "id"}};
+						-- should be assumed to be array since it has 'items'
+						reactions = { items = { xml = { name = "reaction" } } };
+					};
+				};
+				stanza_ids = {
+					type = "array";
+					items = {
+						xml = {name = "stanza-id"; namespace = "urn:xmpp:sid:0"};
+						type = "object";
+						properties = {
+							id = attr();
+							by = attr();
+						};
+					};
+				};
+			};
+		};
+
+		x = xml.parse [[
+				<message xmlns="jabber:client" xml:lang="en" to="a@test" from="b@test" type="chat" id="1">
+				<body>Hello</body>
+				<delay xmlns='urn:xmpp:delay' from='test' stamp='2021-03-07T15:59:08+00:00'>Because</delay>
+				<UNRELATED xmlns='http://jabber.org/protocol/chatstates'/>
+				<active xmlns='http://jabber.org/protocol/chatstates'/>
+				<fallback xmlns='urn:xmpp:fallback:0'/>
+				<origin-id xmlns='urn:xmpp:sid:0' id='qgkmMdPB'/>
+				<stanza-id xmlns='urn:xmpp:sid:0' id='abc1' by='muc'/>
+				<stanza-id xmlns='urn:xmpp:sid:0' id='xyz2' by='host'/>
+				<reactions id='744f6e18-a57a-11e9-a656-4889e7820c76' xmlns='urn:xmpp:reactions:0'>
+					<reaction>👋</reaction>
+					<reaction>🐢</reaction>
+				</reactions>
+				</message>
+				]];
+
+		d = {
+			to = "a@test";
+			from = "b@test";
+			type = "chat";
+			id = "1";
+			lang = "en";
+			body = "Hello";
+			delay = {from = "test"; stamp = "2021-03-07T15:59:08+00:00"; reason = "Because"};
+			state = "active";
+			fallback = true;
+			origin_id = "qgkmMdPB";
+			stanza_ids = {{id = "abc1"; by = "muc"}; {id = "xyz2"; by = "host"}};
+			react = {
+				to = "744f6e18-a57a-11e9-a656-4889e7820c76";
+				reactions = {
+					"👋",
+					"🐢",
+				};
+			};
+		};
+
+		disco_schema = {
+			["$defs"] = { attr = { type = "string"; xml = { attribute = true } } };
+			type = "object";
+			xml = {
+				name = "iq";
+				namespace = "jabber:client"
+			};
+			properties = {
+				to = attr();
+				from = attr();
+				type = attr();
+				id = attr();
+				disco = {
+					type = "object";
+					xml = {
+						name = "query";
+						namespace	= "http://jabber.org/protocol/disco#info"
+					};
+					properties = {
+						features = {
+							type = "array";
+							items = {
+								type = "string";
+								xml = {
+									name = "feature";
+									x_single_attribute = "var";
+								};
+							};
+						};
+					};
+				};
+			};
+		};
+
+		disco_info = xml.parse[[
+		<iq type="result" id="disco1" from="example.com">
+			<query xmlns="http://jabber.org/protocol/disco#info">
+				<feature var="urn:example:feature:1">wrong</feature>
+				<feature var="urn:example:feature:2"/>
+				<feature var="urn:example:feature:3"/>
+				<unrelated var="urn:example:feature:not"/>
+			</query>
+		</iq>
+		]];
+
+		disco = {
+			type="result";
+			id="disco1";
+			from="example.com";
+			disco = {
+				features = {
+					"urn:example:feature:1";
+					"urn:example:feature:2";
+					"urn:example:feature:3";
+				};
+			};
+		};
+	end);
+
+	describe("parse", function()
+		it("works", function()
+			assert.same(d, map.parse(s, x));
+		end);
+
+		it("handles arrays", function ()
+			assert.same(disco, map.parse(disco_schema, disco_info));
+		end);
+
+		it("deals with locally built stanzas", function()
+			-- FIXME this could also be argued to be a util.stanza problem
+			local ver_schema = {
+				type = "object";
+				xml = {name = "iq"};
+				properties = {
+					type = {type = "string"; xml = {attribute = true}};
+					id = {type = "string"; xml = {attribute = true}};
+					version = {
+						type = "object";
+						xml = {name = "query"; namespace = "jabber:iq:version"};
+						-- properties should be assumed to be strings
+						properties = {name = true; version = {}; os = {}};
+					};
+				};
+			};
+			local ver_st = st.iq({type = "result"; id = "v1"})
+				:query("jabber:iq:version")
+					:text_tag("name", "Prosody")
+					:text_tag("version", "trunk")
+					:text_tag("os", "Lua 5.3")
+				:reset();
+
+			local data = {type = "result"; id = "v1"; version = {name = "Prosody"; version = "trunk"; os = "Lua 5.3"}}
+			assert.same(data, map.parse(ver_schema, ver_st));
+		end);
+
+	end);
+
+	describe("unparse", function()
+		it("works", function()
+			local u = map.unparse(s, d);
+			assert.equal("message", u.name);
+			assert.same(x.attr, u.attr);
+			assert.equal(x:get_child_text("body"), u:get_child_text("body"));
+			assert.equal(x:get_child_text("delay", "urn:xmpp:delay"), u:get_child_text("delay", "urn:xmpp:delay"));
+			assert.same(x:get_child("delay", "urn:xmpp:delay").attr, u:get_child("delay", "urn:xmpp:delay").attr);
+			assert.same(x:get_child("origin-id", "urn:xmpp:sid:0").attr, u:get_child("origin-id", "urn:xmpp:sid:0").attr);
+			assert.same(x:get_child("reactions", "urn:xmpp:reactions:0").attr, u:get_child("reactions", "urn:xmpp:reactions:0").attr);
+			assert.same(2, #u:get_child("reactions", "urn:xmpp:reactions:0").tags);
+			for _, tag in ipairs(x.tags) do
+				if tag.name ~= "UNRELATED" then
+					assert.truthy(u:get_child(tag.name, tag.attr.xmlns) or u:get_child(tag.name), tag:top_tag())
+				end
+			end
+			assert.equal(#x.tags-1, #u.tags)
+
+		end);
+
+		it("handles arrays", function ()
+			local u = map.unparse(disco_schema, disco);
+			assert.equal("urn:example:feature:1", u:find("{http://jabber.org/protocol/disco#info}query/feature/@var"))
+			local n = 0;
+			for child in u:get_child("query", "http://jabber.org/protocol/disco#info"):childtags("feature") do
+				n = n + 1;
+				assert.equal(string.format("urn:example:feature:%d", n), child.attr.var);
+			end
+		end);
+
+	end);
+end)
--- a/spec/util_dbuffer_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_dbuffer_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -36,6 +36,29 @@
 		end);
 	end);
 
+	describe(":read_until", function ()
+		it("works", function ()
+			local b = dbuffer.new();
+			b:write("hello\n");
+			b:write("world");
+			b:write("\n");
+			b:write("\n\n");
+			b:write("stuff");
+			b:write("more\nand more");
+
+			assert.equal(nil, b:read_until("."));
+			assert.equal(nil, b:read_until("%"));
+			assert.equal("hello\n", b:read_until("\n"));
+			assert.equal("world\n", b:read_until("\n"));
+			assert.equal("\n", b:read_until("\n"));
+			assert.equal("\n", b:read_until("\n"));
+			assert.equal("stu", b:read(3));
+			assert.equal("ffmore\n", b:read_until("\n"));
+			assert.equal(nil, b:read_until("\n"));
+			assert.equal("and more", b:read_chunk());
+		end);
+	end);
+
 	describe(":discard", function ()
 		local b = dbuffer.new();
 		it("works", function ()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_envload_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,22 @@
+describe("util.envload", function()
+	local envload = require "util.envload";
+	describe("envload()", function()
+		it("works", function()
+			local f, err = envload.envload("return 'hello'", "@test", {});
+			assert.is_function(f, err);
+			local ok, ret = pcall(f);
+			assert.truthy(ok);
+			assert.equal("hello", ret);
+		end);
+		it("lets you pass values in and out", function ()
+			local f, err = envload.envload("return thisglobal", "@test", { thisglobal = "yes, this one" });
+			assert.is_function(f, err);
+			local ok, ret = pcall(f);
+			assert.truthy(ok);
+			assert.equal("yes, this one", ret);
+
+		end);
+
+	end)
+	-- TODO envloadfile()
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_error_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,216 @@
+local errors = require "util.error"
+
+describe("util.error", function ()
+	describe("new()", function ()
+		it("works", function ()
+			local err = errors.new("bork", "bork bork");
+			assert.not_nil(err);
+			assert.equal("cancel", err.type);
+			assert.equal("undefined-condition", err.condition);
+			assert.same("bork bork", err.context);
+		end);
+
+		describe("templates", function ()
+			it("works", function ()
+				local templates = {
+					["fail"] = {
+						type = "wait",
+						condition = "internal-server-error",
+						code = 555;
+					};
+				};
+				local err = errors.new("fail", { traceback = "in some file, somewhere" }, templates);
+				assert.equal("wait", err.type);
+				assert.equal("internal-server-error", err.condition);
+				assert.equal(555, err.code);
+				assert.same({ traceback = "in some file, somewhere" }, err.context);
+			end);
+		end);
+
+	end);
+
+	describe("is_err()", function ()
+		it("works", function ()
+			assert.truthy(errors.is_err(errors.new()));
+			assert.falsy(errors.is_err("not an error"));
+		end);
+	end);
+
+	describe("coerce", function ()
+		it("works", function ()
+			local ok, err = errors.coerce(nil, "it dun goofed");
+			assert.is_nil(ok);
+			assert.truthy(errors.is_err(err))
+		end);
+	end);
+
+	describe("from_stanza", function ()
+		it("works", function ()
+			local st = require "util.stanza";
+			local m = st.message({ type = "chat" });
+			local e = st.error_reply(m, "modify", "bad-request", nil, "error.example"):tag("extra", { xmlns = "xmpp:example.test" });
+			local err = errors.from_stanza(e);
+			assert.truthy(errors.is_err(err));
+			assert.equal("modify", err.type);
+			assert.equal("bad-request", err.condition);
+			assert.equal(e, err.context.stanza);
+			assert.equal("error.example", err.context.by);
+			assert.not_nil(err.extra.tag);
+		end);
+	end);
+
+	describe("__tostring", function ()
+		it("doesn't throw", function ()
+			assert.has_no.errors(function ()
+				-- See 6f317e51544d
+				tostring(errors.new());
+			end);
+		end);
+	end);
+
+	describe("extra", function ()
+		it("keeps some extra fields", function ()
+			local err = errors.new({condition="gone",text="Sorry mate, it's all gone",extra={uri="file:///dev/null"}});
+			assert.is_table(err.extra);
+			assert.equal("file:///dev/null", err.extra.uri);
+		end);
+	end)
+
+	describe("init", function()
+		it("basics works", function()
+			local reg = errors.init("test", {
+				broke = {type = "cancel"; condition = "internal-server-error"; text = "It broke :("};
+				nope = {type = "auth"; condition = "not-authorized"; text = "Can't let you do that Dave"};
+			});
+
+			local broke = reg.new("broke");
+			assert.equal("cancel", broke.type);
+			assert.equal("internal-server-error", broke.condition);
+			assert.equal("It broke :(", broke.text);
+			assert.equal("test", broke.source);
+
+			local nope = reg.new("nope");
+			assert.equal("auth", nope.type);
+			assert.equal("not-authorized", nope.condition);
+			assert.equal("Can't let you do that Dave", nope.text);
+		end);
+
+		it("compact mode works", function()
+			local reg = errors.init("test", "spec", {
+				broke = {"cancel"; "internal-server-error"; "It broke :("};
+				nope = {"auth"; "not-authorized"; "Can't let you do that Dave"; "sorry-dave"};
+			});
+
+			local broke = reg.new("broke");
+			assert.equal("cancel", broke.type);
+			assert.equal("internal-server-error", broke.condition);
+			assert.equal("It broke :(", broke.text);
+			assert.is_nil(broke.extra);
+
+			local nope = reg.new("nope");
+			assert.equal("auth", nope.type);
+			assert.equal("not-authorized", nope.condition);
+			assert.equal("Can't let you do that Dave", nope.text);
+			assert.equal("spec", nope.extra.namespace);
+			assert.equal("sorry-dave", nope.extra.condition);
+		end);
+
+		it("registry looks the same regardless of syntax", function()
+			local normal = errors.init("test", {
+				broke = {type = "cancel"; condition = "internal-server-error"; text = "It broke :("};
+				nope = {
+					type = "auth";
+					condition = "not-authorized";
+					text = "Can't let you do that Dave";
+					extra = {namespace = "spec"; condition = "sorry-dave"};
+				};
+			});
+			local compact1 = errors.init("test", "spec", {
+				broke = {"cancel"; "internal-server-error"; "It broke :("};
+				nope = {"auth"; "not-authorized"; "Can't let you do that Dave"; "sorry-dave"};
+			});
+			local compact2 = errors.init("test", {
+				broke = {"cancel"; "internal-server-error"; "It broke :("};
+				nope = {"auth"; "not-authorized"; "Can't let you do that Dave"};
+			});
+			assert.same(normal.registry, compact1.registry);
+
+			assert.same({
+				broke = {type = "cancel"; condition = "internal-server-error"; text = "It broke :("};
+				nope = {type = "auth"; condition = "not-authorized"; text = "Can't let you do that Dave"};
+			}, compact2.registry);
+		end);
+
+		describe(".wrap", function ()
+			local reg = errors.init("test", "spec", {
+				myerror = { "cancel", "internal-server-error", "Oh no" };
+			});
+			it("is exposed", function ()
+				assert.is_function(reg.wrap);
+			end);
+			it("returns errors according to the registry", function ()
+				local e = reg.wrap("myerror");
+				assert.equal("cancel", e.type);
+				assert.equal("internal-server-error", e.condition);
+				assert.equal("Oh no", e.text);
+			end);
+
+			it("passes through existing errors", function ()
+				local e = reg.wrap(reg.new({ type = "auth", condition = "forbidden" }));
+				assert.equal("auth", e.type);
+				assert.equal("forbidden", e.condition);
+			end);
+
+			it("wraps arbitrary values", function ()
+				local e = reg.wrap(123);
+				assert.equal("cancel", e.type);
+				assert.equal("undefined-condition", e.condition);
+				assert.equal(123, e.context.wrapped_error);
+			end);
+		end);
+
+		describe(".coerce", function ()
+			local reg = errors.init("test", "spec", {
+				myerror = { "cancel", "internal-server-error", "Oh no" };
+			});
+
+			it("is exposed", function ()
+				assert.is_function(reg.coerce);
+			end);
+
+			it("passes through existing errors", function ()
+				local function test()
+					return nil, errors.new({ type = "auth", condition = "forbidden" });
+				end
+				local ok, err = reg.coerce(test());
+				assert.is_nil(ok);
+				assert.is_truthy(errors.is_err(err));
+				assert.equal("forbidden", err.condition);
+			end);
+
+			it("passes through successful return values", function ()
+				local function test()
+					return 1, 2, 3, 4;
+				end
+				local one, two, three, four = reg.coerce(test());
+				assert.equal(1, one);
+				assert.equal(2, two);
+				assert.equal(3, three);
+				assert.equal(4, four);
+			end);
+
+			it("wraps non-error objects", function ()
+				local function test()
+					return nil, "myerror";
+				end
+				local ok, err = reg.coerce(test());
+				assert.is_nil(ok);
+				assert.is_truthy(errors.is_err(err));
+				assert.equal("internal-server-error", err.condition);
+				assert.equal("Oh no", err.text);
+			end);
+		end);
+	end);
+
+end);
+
--- a/spec/util_events_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_events_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -208,5 +208,43 @@
 				assert.spy(h).was_called(2);
 			end);
 		end);
+
+		describe("debug hooks", function ()
+			it("should get called", function ()
+				local d = spy.new(function (handler, event_name, event_data) --luacheck: ignore 212/event_name
+					return handler(event_data);
+				end);
+
+				e.add_handler("myevent", h);
+				e.fire_event("myevent");
+
+				assert.spy(h).was_called(1);
+				assert.spy(d).was_called(0);
+
+				assert.is_nil(e.set_debug_hook(d));
+
+				e.fire_event("myevent", { mydata = true });
+
+				assert.spy(h).was_called(2);
+				assert.spy(d).was_called(1);
+				assert.spy(d).was_called_with(h, "myevent", { mydata = true });
+
+				assert.equal(d, e.set_debug_hook(nil));
+
+				e.fire_event("myevent", { mydata = false });
+
+				assert.spy(h).was_called(3);
+				assert.spy(d).was_called(1);
+			end);
+			it("setting should return any existing debug hook", function ()
+				local function f() end
+				local function g() end
+				assert.is_nil(e.set_debug_hook(f));
+				assert.is_equal(f, e.set_debug_hook(g));
+				assert.is_equal(g, e.set_debug_hook(f));
+				assert.is_equal(f, e.set_debug_hook(nil));
+				assert.is_nil(e.set_debug_hook(f));
+			end);
+		end);
 	end);
 end);
--- a/spec/util_format_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_format_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,14 +1,901 @@
 local format = require "util.format".format;
+-- There are eight basic types in Lua:
+-- nil, boolean, number, string, function, userdata, thread, and table
 
 describe("util.format", function()
 	describe("#format()", function()
 		it("should work", function()
 			assert.equal("hello", format("%s", "hello"));
-			assert.equal("<nil>", format("%s"));
-			assert.equal(" [<nil>]", format("", nil));
+			assert.equal("(nil)", format("%s"));
+			assert.equal("(nil)", format("%d"));
+			assert.equal("(nil)", format("%q"));
+			assert.equal(" [(nil)]", format("", nil));
 			assert.equal("true", format("%s", true));
 			assert.equal("[true]", format("%d", true));
 			assert.equal("% [true]", format("%%", true));
+			assert.equal("{ }", format("%q", { }));
+			assert.equal("[1.5]", format("%d", 1.5));
+			assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464));
 		end);
+
+		it("escapes ascii control stuff", function ()
+			assert.equal("␁", format("%s", "\1"));
+			assert.equal("[␁]", format("%d", "\1"));
+		end);
+
+		it("escapes invalid UTF-8", function ()
+			assert.equal("\"Hello w\\195rld\"", format("%s", "Hello w\195rld"));
+		end);
+
+		if _VERSION >= "Lua 5.4" then
+			it("handles %p formats", function ()
+				assert.matches("a 0x%x+ b", format("%s %p %s", "a", {}, "b"));
+			end)
+		else
+			it("does something with %p formats", function ()
+				assert.string(format("%p", {}));
+			end)
+		end
+
+		it("escapes multi-line strings", function ()
+			assert.equal("Hello\n\tWorld", format("%s", "Hello\nWorld"))
+			assert.equal("\"Hello\\nWorld\"", format("%q", "Hello\nWorld"))
+		end)
+
+		-- Tests generated with loops!
+		describe("nil", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%c", nil))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%d", nil))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%i", nil))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%o", nil))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%u", nil))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%x", nil))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%X", nil))
+				end);
+			end);
+
+			describe("to %a", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%a", nil))
+				end);
+			end);
+
+			describe("to %A", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%A", nil))
+				end);
+			end);
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%e", nil))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%E", nil))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%f", nil))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%g", nil))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%G", nil))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%q", nil))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.equal("(nil)", format("%s", nil))
+				end);
+			end);
+
+		end);
+
+		describe("boolean", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%c", true))
+					assert.equal("[false]", format("%c", false))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%d", true))
+					assert.equal("[false]", format("%d", false))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%i", true))
+					assert.equal("[false]", format("%i", false))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%o", true))
+					assert.equal("[false]", format("%o", false))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%u", true))
+					assert.equal("[false]", format("%u", false))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%x", true))
+					assert.equal("[false]", format("%x", false))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%X", true))
+					assert.equal("[false]", format("%X", false))
+				end);
+			end);
+
+			describe("to %a", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%a", true))
+					assert.equal("[false]", format("%a", false))
+				end);
+			end);
+
+			describe("to %A", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%A", true))
+					assert.equal("[false]", format("%A", false))
+				end);
+			end);
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%e", true))
+					assert.equal("[false]", format("%e", false))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%E", true))
+					assert.equal("[false]", format("%E", false))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%f", true))
+					assert.equal("[false]", format("%f", false))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%g", true))
+					assert.equal("[false]", format("%g", false))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.equal("[true]", format("%G", true))
+					assert.equal("[false]", format("%G", false))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.equal("true", format("%q", true))
+					assert.equal("false", format("%q", false))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.equal("true", format("%s", true))
+					assert.equal("false", format("%s", false))
+				end);
+			end);
+
+		end);
+
+		describe("number", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.equal("a", format("%c", 97))
+					assert.equal("[1.5]", format("%c", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%c", 73786976294838206464))
+					assert.equal("[inf]", format("%c", math.huge))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.equal("97", format("%d", 97))
+					assert.equal("-12345", format("%d", -12345))
+					assert.equal("[1.5]", format("%d", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464))
+					assert.equal("[inf]", format("%d", math.huge))
+					assert.equal("2147483647", format("%d", 2147483647))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.equal("97", format("%i", 97))
+					assert.equal("-12345", format("%i", -12345))
+					assert.equal("[1.5]", format("%i", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%i", 73786976294838206464))
+					assert.equal("[inf]", format("%i", math.huge))
+					assert.equal("2147483647", format("%i", 2147483647))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.equal("141", format("%o", 97))
+					assert.equal("[-12345]", format("%o", -12345))
+					assert.equal("[1.5]", format("%o", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%o", 73786976294838206464))
+					assert.equal("[inf]", format("%o", math.huge))
+					assert.equal("17777777777", format("%o", 2147483647))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.equal("97", format("%u", 97))
+					assert.equal("[-12345]", format("%u", -12345))
+					assert.equal("[1.5]", format("%u", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%u", 73786976294838206464))
+					assert.equal("[inf]", format("%u", math.huge))
+					assert.equal("2147483647", format("%u", 2147483647))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.equal("61", format("%x", 97))
+					assert.equal("[-12345]", format("%x", -12345))
+					assert.equal("[1.5]", format("%x", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%x", 73786976294838206464))
+					assert.equal("[inf]", format("%x", math.huge))
+					assert.equal("7fffffff", format("%x", 2147483647))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.equal("61", format("%X", 97))
+					assert.equal("[-12345]", format("%X", -12345))
+					assert.equal("[1.5]", format("%X", 1.5))
+					assert.equal("[7.3786976294838e+19]", format("%X", 73786976294838206464))
+					assert.equal("[inf]", format("%X", math.huge))
+					assert.equal("7FFFFFFF", format("%X", 2147483647))
+				end);
+			end);
+
+			if _VERSION > "Lua 5.1" then -- COMPAT no %a or %A in Lua 5.1
+				describe("to %a", function ()
+					it("works", function ()
+						assert.equal("0x1.84p+6", format("%a", 97))
+						assert.equal("-0x1.81c8p+13", format("%a", -12345))
+						assert.equal("0x1.8p+0", format("%a", 1.5))
+						assert.equal("0x1p+66", format("%a", 73786976294838206464))
+						assert.equal("inf", format("%a", math.huge))
+						assert.equal("0x1.fffffffcp+30", format("%a", 2147483647))
+					end);
+				end);
+
+				describe("to %A", function ()
+					it("works", function ()
+						assert.equal("0X1.84P+6", format("%A", 97))
+						assert.equal("-0X1.81C8P+13", format("%A", -12345))
+						assert.equal("0X1.8P+0", format("%A", 1.5))
+						assert.equal("0X1P+66", format("%A", 73786976294838206464))
+						assert.equal("INF", format("%A", math.huge))
+						assert.equal("0X1.FFFFFFFCP+30", format("%A", 2147483647))
+					end);
+				end);
+			end
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.equal("9.700000e+01", format("%e", 97))
+					assert.equal("-1.234500e+04", format("%e", -12345))
+					assert.equal("1.500000e+00", format("%e", 1.5))
+					assert.equal("7.378698e+19", format("%e", 73786976294838206464))
+					assert.equal("inf", format("%e", math.huge))
+					assert.equal("2.147484e+09", format("%e", 2147483647))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.equal("9.700000E+01", format("%E", 97))
+					assert.equal("-1.234500E+04", format("%E", -12345))
+					assert.equal("1.500000E+00", format("%E", 1.5))
+					assert.equal("7.378698E+19", format("%E", 73786976294838206464))
+					assert.equal("INF", format("%E", math.huge))
+					assert.equal("2.147484E+09", format("%E", 2147483647))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.equal("97.000000", format("%f", 97))
+					assert.equal("-12345.000000", format("%f", -12345))
+					assert.equal("1.500000", format("%f", 1.5))
+					assert.equal("73786976294838206464.000000", format("%f", 73786976294838206464))
+					assert.equal("inf", format("%f", math.huge))
+					assert.equal("2147483647.000000", format("%f", 2147483647))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.equal("97", format("%g", 97))
+					assert.equal("-12345", format("%g", -12345))
+					assert.equal("1.5", format("%g", 1.5))
+					assert.equal("7.3787e+19", format("%g", 73786976294838206464))
+					assert.equal("inf", format("%g", math.huge))
+					assert.equal("2.14748e+09", format("%g", 2147483647))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.equal("97", format("%G", 97))
+					assert.equal("-12345", format("%G", -12345))
+					assert.equal("1.5", format("%G", 1.5))
+					assert.equal("7.3787E+19", format("%G", 73786976294838206464))
+					assert.equal("INF", format("%G", math.huge))
+					assert.equal("2.14748E+09", format("%G", 2147483647))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.equal("97", format("%q", 97))
+					assert.equal("-12345", format("%q", -12345))
+					assert.equal("1.5", format("%q", 1.5))
+					assert.equal("7.37869762948382065e+19", format("%q", 73786976294838206464))
+					assert.equal("(1/0)", format("%q", math.huge))
+					assert.equal("2147483647", format("%q", 2147483647))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.equal("97", format("%s", 97))
+					assert.equal("-12345", format("%s", -12345))
+					assert.equal("1.5", format("%s", 1.5))
+					assert.equal("7.3786976294838e+19", format("%s", 73786976294838206464))
+					assert.equal("inf", format("%s", math.huge))
+					assert.equal("2147483647", format("%s", 2147483647))
+				end);
+			end);
+
+		end);
+
+		describe("string", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%c", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%c", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%c", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%c", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%d", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%d", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%d", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%d", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%i", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%i", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%i", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%i", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%o", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%o", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%o", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%o", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%u", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%u", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%u", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%u", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%x", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%x", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%x", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%x", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%X", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%X", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%X", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%X", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %a", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%a", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%a", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%a", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%a", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %A", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%A", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%A", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%A", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%A", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%e", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%e", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%e", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%e", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%E", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%E", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%E", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%E", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%f", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%f", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%f", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%f", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%g", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%g", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%g", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%g", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.equal("[hello]", format("%G", "hello"))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%G", "foo \001\002\003 bar"))
+					assert.equal("[nödåtgärd]", format("%G", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%G", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.equal("\"hello\"", format("%q", "hello"))
+					assert.equal("\"foo \\001\\002\\003 bar\"", format("%q", "foo \001\002\003 bar"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\\164rd\"", format("%q", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%q", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.equal("hello", format("%s", "hello"))
+					assert.equal("foo \226\144\129\226\144\130\226\144\131 bar", format("%s", "foo \001\002\003 bar"))
+					assert.equal("nödåtgärd", format("%s", "n\195\182d\195\165tg\195\164rd"))
+					assert.equal("\"n\\195\\182d\\195\\165tg\\195\"", format("%s", "n\195\182d\195\165tg\195"))
+				end);
+			end);
+
+		end);
+
+		describe("function", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%c", function() end))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%d", function() end))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%i", function() end))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%o", function() end))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%u", function() end))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%x", function() end))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%X", function() end))
+				end);
+			end);
+
+			describe("to %a", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%a", function() end))
+				end);
+			end);
+
+			describe("to %A", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%A", function() end))
+				end);
+			end);
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%e", function() end))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%E", function() end))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%f", function() end))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%g", function() end))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.matches("[function: 0[xX]%x+]", format("%G", function() end))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.matches('{__type="function",__error="fail"}', format("%q", function() end))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.matches("function: 0[xX]%x+", format("%s", function() end))
+				end);
+			end);
+
+		end);
+
+		describe("thread", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%c", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%d", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%i", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%o", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%u", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%x", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%X", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %a", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%a", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %A", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%A", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%e", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%E", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%f", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%g", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.matches("[thread: 0[xX]%x+]", format("%G", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.matches('{__type="thread",__error="fail"}', format("%q", coroutine.create(function() end)))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.matches("thread: 0[xX]%x+", format("%s", coroutine.create(function() end)))
+				end);
+			end);
+
+		end);
+
+		describe("table", function ()
+			describe("to %c", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%c", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%c", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %d", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%d", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%d", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %i", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%i", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%i", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %o", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%o", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%o", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %u", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%u", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%u", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %x", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%x", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%x", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %X", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%X", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%X", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %a", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%a", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%a", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %A", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%A", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%A", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %e", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%e", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%e", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %E", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%E", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%E", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %f", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%f", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%f", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %g", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%g", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%g", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %G", function ()
+				it("works", function ()
+					assert.matches("[table: 0[xX]%x+]", format("%G", { }))
+					assert.equal("[foo \226\144\129\226\144\130\226\144\131 bar]", format("%G", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %q", function ()
+				it("works", function ()
+					assert.matches("{ }", format("%q", { }))
+					assert.equal("{ }", format("%q", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+			describe("to %s", function ()
+				it("works", function ()
+					assert.matches("table: 0[xX]%x+", format("%s", { }))
+					assert.equal("foo \226\144\129\226\144\130\226\144\131 bar", format("%s", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+				end);
+			end);
+
+		end);
+
+
 	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_hashes_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,55 @@
+-- Test vectors from RFC 6070
+local hashes = require "util.hashes";
+local hex = require "util.hex";
+
+-- Also see spec for util.hmac where HMAC test cases reside
+
+describe("PBKDF2-HMAC-SHA1", function ()
+	it("test vector 1", function ()
+		local P = "password"
+		local S = "salt"
+		local c = 1
+		local DK = "0c60c80f961f0e71f3a9b524af6012062fe037a6";
+		assert.equal(DK, hex.encode(hashes.pbkdf2_hmac_sha1(P, S, c)));
+	end);
+	it("test vector 2", function ()
+		local P = "password"
+		local S = "salt"
+		local c = 2
+		local DK = "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957";
+		assert.equal(DK, hex.encode(hashes.pbkdf2_hmac_sha1(P, S, c)));
+	end);
+	it("test vector 3", function ()
+		local P = "password"
+		local S = "salt"
+		local c = 4096
+		local DK = "4b007901b765489abead49d926f721d065a429c1";
+		assert.equal(DK, hex.encode(hashes.pbkdf2_hmac_sha1(P, S, c)));
+	end);
+	it("test vector 4 #SLOW", function ()
+		local P = "password"
+		local S = "salt"
+		local c = 16777216
+		local DK = "eefe3d61cd4da4e4e9945b3d6ba2158c2634e984";
+		assert.equal(DK, hex.encode(hashes.pbkdf2_hmac_sha1(P, S, c)));
+	end);
+end);
+
+describe("PBKDF2-HMAC-SHA256", function ()
+	it("test vector 1", function ()
+		local P = "password";
+		local S = "salt";
+		local c = 1
+		local DK = "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b";
+		assert.equal(DK, hex.encode(hashes.pbkdf2_hmac_sha256(P, S, c)));
+	end);
+	it("test vector 2", function ()
+		local P = "password";
+		local S = "salt";
+		local c = 2
+		local DK = "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43";
+		assert.equal(DK, hex.encode(hashes.pbkdf2_hmac_sha256(P, S, c)));
+	end);
+end);
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_hashring_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,85 @@
+local hashring = require "util.hashring";
+
+describe("util.hashring", function ()
+
+	local sha256 = require "util.hashes".sha256;
+
+	local ring = hashring.new(128, sha256);
+
+	it("should fail to get a node that does not exist", function ()
+		assert.is_nil(ring:get_node("foo"))
+	end);
+
+	it("should support adding nodes", function ()
+		ring:add_node("node1");
+	end);
+
+	it("should return a single node for all keys if only one node exists", function ()
+		for i = 1, 100 do
+			assert.is_equal("node1", ring:get_node(tostring(i)))
+		end
+	end);
+
+	it("should support adding a second node", function ()
+		ring:add_node("node2");
+	end);
+
+	it("should fail to remove a non-existent node", function ()
+		assert.is_falsy(ring:remove_node("node3"));
+	end);
+
+	it("should succeed to remove a node", function ()
+		assert.is_truthy(ring:remove_node("node1"));
+	end);
+
+	it("should return the only node for all keys", function ()
+		for i = 1, 100 do
+			assert.is_equal("node2", ring:get_node(tostring(i)))
+		end
+	end);
+
+	it("should support adding multiple nodes", function ()
+		ring:add_nodes({ "node1", "node3", "node4", "node5" });
+	end);
+
+	it("should disrupt a minimal number of keys on node removal", function ()
+		local orig_ring = ring:clone();
+		local node_tallies = {};
+
+		local n = 1000;
+
+		for i = 1, n do
+			local key = tostring(i);
+			local node = ring:get_node(key);
+			node_tallies[node] = (node_tallies[node] or 0) + 1;
+		end
+
+		--[[
+		for node, key_count in pairs(node_tallies) do
+			print(node, key_count, ("%.2f%%"):format((key_count/n)*100));
+		end
+		]]
+
+		ring:remove_node("node5");
+
+		local disrupted_keys = 0;
+		for i = 1, n do
+			local key = tostring(i);
+			if orig_ring:get_node(key) ~= ring:get_node(key) then
+				disrupted_keys = disrupted_keys + 1;
+			end
+		end
+		assert.is_equal(node_tallies["node5"], disrupted_keys);
+	end);
+
+	it("should support removing multiple nodes", function ()
+		ring:remove_nodes({"node2", "node3", "node4", "node5"});
+	end);
+
+	it("should return a single node for all keys if only one node remains", function ()
+		for i = 1, 100 do
+			assert.is_equal("node1", ring:get_node(tostring(i)))
+		end
+	end);
+
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_hmac_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,106 @@
+-- Test cases from RFC 4231
+
+-- Yes, the lines are long, it's annoying to split the long hex things.
+-- luacheck: ignore 631
+
+local hmac = require "util.hmac";
+local hex = require "util.hex";
+
+describe("Test case 1", function ()
+	local Key  = hex.decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
+	local Data = hex.decode("4869205468657265");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7", hmac.sha256(Key, Data, true))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854", hmac.sha512(Key, Data, true))
+		end);
+	end);
+end);
+describe("Test case 2", function ()
+	local Key  = hex.decode("4a656665");
+	local Data = hex.decode("7768617420646f2079612077616e7420666f72206e6f7468696e673f");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843", hmac.sha256(Key, Data, true))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea2505549758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737", hmac.sha512(Key, Data, true))
+		end);
+	end);
+end);
+describe("Test case 3", function ()
+	local Key  = hex.decode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+	local Data = hex.decode("dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("773ea91e36800e46854db8ebd09181a72959098b3ef8c122d9635514ced565fe", hmac.sha256(Key, Data, true))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("fa73b0089d56a284efb0f0756c890be9b1b5dbdd8ee81a3655f83e33b2279d39bf3e848279a722c806b485a47e67c807b946a337bee8942674278859e13292fb", hmac.sha512(Key, Data, true))
+		end);
+	end);
+end);
+describe("Test case 4", function ()
+	local Key  = hex.decode("0102030405060708090a0b0c0d0e0f10111213141516171819");
+	local Data = hex.decode("cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("82558a389a443c0ea4cc819899f2083a85f0faa3e578f8077a2e3ff46729665b", hmac.sha256(Key, Data, true))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("b0ba465637458c6990e5a8c5f61d4af7e576d97ff94b872de76f8050361ee3dba91ca5c11aa25eb4d679275cc5788063a5f19741120c4f2de2adebeb10a298dd", hmac.sha512(Key, Data, true))
+		end);
+	end);
+end);
+describe("Test case 5", function ()
+	local Key  = hex.decode("0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c");
+	local Data = hex.decode("546573742057697468205472756e636174696f6e");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("a3b6167473100ee06e0c796c2955552b", hmac.sha256(Key, Data, true):sub(1,128/4))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("415fad6271580a531d4179bc891d87a6", hmac.sha512(Key, Data, true):sub(1,128/4))
+		end);
+	end);
+end);
+describe("Test case 6", function ()
+	local Key  = hex.decode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+	local Data = hex.decode("54657374205573696e67204c6172676572205468616e20426c6f636b2d53697a65204b6579202d2048617368204b6579204669727374");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54", hmac.sha256(Key, Data, true))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("80b24263c7c1a3ebb71493c1dd7be8b49b46d1f41b4aeec1121b013783f8f3526b56d037e05f2598bd0fd2215d6a1e5295e64f73f63f0aec8b915a985d786598", hmac.sha512(Key, Data, true))
+		end);
+	end);
+end);
+describe("Test case 7", function ()
+	local Key  = hex.decode("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
+	local Data = hex.decode("5468697320697320612074657374207573696e672061206c6172676572207468616e20626c6f636b2d73697a65206b657920616e642061206c6172676572207468616e20626c6f636b2d73697a6520646174612e20546865206b6579206e6565647320746f20626520686173686564206265666f7265206265696e6720757365642062792074686520484d414320616c676f726974686d2e");
+	describe("HMAC-SHA-256", function ()
+		it("works", function()
+			assert.equal("9b09ffa71b942fcb27635fbcd5b0e944bfdc63644f0713938a7f51535c3a35e2", hmac.sha256(Key, Data, true))
+		end);
+	end);
+	describe("HMAC-SHA-512", function ()
+		it("works", function()
+			assert.equal("e37b6a775dc87dbaa4dfa9f96e5e3ffddebd71f8867289865df5a32d20cdc944b6022cac3c4982b10d5eeb55c3e4de15134676fb6de0446065c97440fa8c6a58", hmac.sha512(Key, Data, true))
+		end);
+	end);
+end);
--- a/spec/util_http_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_http_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -28,6 +28,11 @@
 		it("should decode important URL characters", function()
 			assert.are.equal("This & that = something", http.urldecode("This%20%26%20that%20%3d%20something"), "Important URL chars escaped");
 		end);
+
+		it("should decode both lower and uppercase", function ()
+			assert.are.equal("This & that = {something}.", http.urldecode("This%20%26%20that%20%3D%20%7Bsomething%7D%2E"), "Important URL chars escaped");
+		end);
+
 	end);
 
 	describe("#formencode()", function()
@@ -84,4 +89,23 @@
 			assert.equal("/foo/", http.normalize_path("/foo/", true));
 		end);
 	end);
+
+	describe("contains_token", function ()
+		it("is present in field", function ()
+			assert.is_true(http.contains_token("foo", "foo"));
+			assert.is_true(http.contains_token("foo, bar", "foo"));
+			assert.is_true(http.contains_token("foo,bar", "foo"));
+			assert.is_true(http.contains_token("bar,  foo,baz", "foo"));
+		end);
+
+		it("is absent from field", function ()
+			assert.is_false(http.contains_token("bar", "foo"));
+			assert.is_false(http.contains_token("fooo", "foo"));
+			assert.is_false(http.contains_token("foo o,bar", "foo"));
+		end);
+
+		it("is weird", function ()
+			assert.is_(http.contains_token("fo o", "foo"));
+		end);
+	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_human_io_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,48 @@
+describe("util.human.io", function ()
+	local human_io
+	setup(function ()
+		human_io = require "util.human.io";
+	end);
+	describe("table", function ()
+
+		it("alignment works", function ()
+			local row = human_io.table({
+					{
+						width = 3,
+						align = "right"
+					},
+					{
+						width = 3,
+					},
+				});
+
+			assert.equal("  1 | .  ", row({ 1, "." }));
+			assert.equal(" 10 | .. ", row({ 10, ".." }));
+			assert.equal("100 | ...", row({ 100, "..." }));
+			assert.equal("10… | ..…", row({ 1000, "...." }));
+
+		end);
+	end);
+
+	describe("ellipsis", function()
+		it("works", function()
+			assert.equal("…", human_io.ellipsis("abc", 1));
+			assert.equal("a…", human_io.ellipsis("abc", 2));
+			assert.equal("abc", human_io.ellipsis("abc", 3));
+
+			assert.equal("…", human_io.ellipsis("räksmörgås", 1));
+			assert.equal("r…", human_io.ellipsis("räksmörgås", 2));
+			assert.equal("rä…", human_io.ellipsis("räksmörgås", 3));
+			assert.equal("räk…", human_io.ellipsis("räksmörgås", 4));
+			assert.equal("räks…", human_io.ellipsis("räksmörgås", 5));
+			assert.equal("räksm…", human_io.ellipsis("räksmörgås", 6));
+			assert.equal("räksmö…", human_io.ellipsis("räksmörgås", 7));
+			assert.equal("räksmör…", human_io.ellipsis("räksmörgås", 8));
+			assert.equal("räksmörg…", human_io.ellipsis("räksmörgås", 9));
+			assert.equal("räksmörgås", human_io.ellipsis("räksmörgås", 10));
+		end);
+	end);
+end);
+
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_human_units_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,15 @@
+local units = require "util.human.units";
+
+describe("util.human.units", function ()
+	describe("format", function ()
+		it("formats numbers with SI units", function ()
+			assert.equal("1 km", units.format(1000, "m"));
+			assert.equal("1 GJ", units.format(1000000000, "J"));
+			assert.equal("1 ms", units.format(1/1000, "s"));
+			assert.equal("10 ms", units.format(10/1000, "s"));
+			assert.equal("1 ns", units.format(1/1000000000, "s"));
+			assert.equal("1 KiB", units.format(1024, "B", 'b'));
+			assert.equal("1 MiB", units.format(1024*1024, "B", 'b'));
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_interpolation_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,66 @@
+local template = [[
+{greet!?Hi}, {name?world}!
+]];
+local expect1 = [[
+Hello, WORLD!
+]];
+local expect2 = [[
+Hello, world!
+]];
+local expect3 = [[
+Hi, YOU!
+]];
+local template_array = [[
+{foo#{idx}. {item}
+}]]
+local expect_array = [[
+1. HELLO
+2. WORLD
+]]
+local template_func_pipe = [[
+{foo|sort#{idx}. {item}
+}]]
+local expect_func_pipe = [[
+1. A
+2. B
+3. C
+4. D
+]]
+local template_map = [[
+{foo%{idx}: {item!}
+}]]
+local expect_map = [[
+FOO: bar
+]]
+local template_not = [[
+{thing~Thing is falsy}{thing&Thing is truthy}
+]]
+local expect_not_true = [[
+Thing is truthy
+]]
+local expect_not_nil = [[
+Thing is falsy
+]]
+local expect_not_false = [[
+Thing is falsy
+]]
+describe("util.interpolation", function ()
+	it("renders", function ()
+		local render = require "util.interpolation".new("%b{}", string.upper, { sort = function (t) table.sort(t) return t end });
+		assert.equal(expect1, render(template, { greet = "Hello", name = "world" }));
+		assert.equal(expect2, render(template, { greet = "Hello" }));
+		assert.equal(expect3, render(template, { name = "you" }));
+		assert.equal(expect_array, render(template_array, { foo = { "Hello", "World" } }));
+		assert.equal(expect_func_pipe, render(template_func_pipe, { foo = { "c", "a", "d", "b", } }));
+		-- assert.equal("", render(template_func_pipe, { foo = nil })); -- FIXME
+		assert.equal(expect_map, render(template_map, { foo = { foo = "bar" } }));
+		assert.equal(expect_not_true, render(template_not, { thing = true }));
+		assert.equal(expect_not_nil, render(template_not, { thing = nil }));
+		assert.equal(expect_not_false, render(template_not, { thing = false }));
+	end);
+	it("fixes #1623", function ()
+		local render = require "util.interpolation".new("%b{}", string.upper, { x = string.lower });
+		assert.equal("", render("{foo?}", {  }))
+		assert.equal("", render("{foo|x?}", {  }))
+	end);
+end);
--- a/spec/util_jid_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_jid_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -13,6 +13,11 @@
 			assert.are.equal(jid.join(nil, nil, "c"), nil, "invalid JID is nil");
 			assert.are.equal(jid.join("a", nil, "c"), nil, "invalid JID is nil");
 		end);
+		it("should reject invalid arguments", function ()
+			assert.has_error(function () jid.join(false, "bork", nil) end)
+			assert.has_error(function () jid.join(nil, "bork", false) end)
+			assert.has_error(function () jid.join(false, false, false) end)
+		end)
 	end);
 	describe("#split()", function()
 		it("should work", function()
@@ -38,6 +43,9 @@
 			test("@server/resource", nil, nil, nil);
 			test("@/resource", nil, nil, nil);
 		end);
+		it("should reject invalid arguments", function ()
+			assert.has_error(function () jid.split(false) end)
+		end)
 	end);
 
 
@@ -59,6 +67,9 @@
 			assert.are.equal(jid.bare("user@@host/resource"), nil, "invalid JID is nil");
 			assert.are.equal(jid.bare("user@host/"), nil, "invalid JID is nil");
 		end);
+		it("should reject invalid arguments", function ()
+			assert.has_error(function () jid.bare(false) end)
+		end)
 	end);
 
 	describe("#compare()", function()
@@ -75,6 +86,56 @@
 		end);
 	end);
 
+	local jid_escaping_test_vectors = {
+		-- From https://xmpp.org/extensions/xep-0106.xml#examples sans @example.com
+		[[space cadet]], [[space\20cadet]],
+		[[call me "ishmael"]], [[call\20me\20\22ishmael\22]],
+		[[at&t guy]], [[at\26t\20guy]],
+		[[d'artagnan]], [[d\27artagnan]],
+		[[/.fanboy]], [[\2f.fanboy]],
+		[[::foo::]], [[\3a\3afoo\3a\3a]],
+		[[<foo>]], [[\3cfoo\3e]],
+		[[user@host]], [[user\40host]],
+		[[c:\net]], [[c\3a\net]],
+		[[c:\\net]], [[c\3a\\net]],
+		[[c:\cool stuff]], [[c\3a\cool\20stuff]],
+		[[c:\5commas]], [[c\3a\5c5commas]],
+
+		-- Section 4.2
+		[[\3and\2is\5cool]], [[\5c3and\2is\5c5cool]],
+
+		-- From aioxmpp
+		[[\5c]], [[\5c5c]],
+    -- [[\5C]], [[\5C]],
+    [[\2plus\2is\4]], [[\2plus\2is\4]],
+    [[foo\bar]], [[foo\bar]],
+    [[foo\41r]], [[foo\41r]],
+    -- additional test vectors
+    [[call\20me]], [[call\5c20me]],
+	};
+
+	describe("#escape()", function ()
+		it("should work", function ()
+			for i = 1, #jid_escaping_test_vectors, 2 do
+				local original = jid_escaping_test_vectors[i];
+				local escaped = jid_escaping_test_vectors[i+1];
+
+				assert.are.equal(escaped, jid.escape(original), ("Escapes '%s' -> '%s'"):format(original, escaped));
+			end
+		end);
+	end)
+
+	describe("#unescape()", function ()
+		it("should work", function ()
+			for i = 1, #jid_escaping_test_vectors, 2 do
+				local original = jid_escaping_test_vectors[i];
+				local escaped = jid_escaping_test_vectors[i+1];
+
+				assert.are.equal(original, jid.unescape(escaped), ("Unescapes '%s' -> '%s'"):format(escaped, original));
+			end
+		end);
+	end)
+
 	it("should work with nodes", function()
 		local function test(_jid, expected_node)
 			assert.are.equal(jid.node(_jid), expected_node, "Unexpected node for "..tostring(_jid));
--- a/spec/util_json_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_json_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,5 +1,6 @@
 
 local json = require "util.json";
+local array = require "util.array";
 
 describe("util.json", function()
 	describe("#encode()", function()
@@ -67,4 +68,13 @@
 			end
 		end);
 	end)
+
+	describe("util.array integration", function ()
+		it("works", function ()
+			assert.equal("[]", json.encode(array()));
+			assert.equal("[1,2,3]", json.encode(array({1,2,3})));
+			assert.equal(getmetatable(array()), getmetatable(json.decode("[]")));
+		end);
+	end);
+
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_jsonpointer_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,38 @@
+describe("util.jsonpointer", function()
+	local json, jp;
+	setup(function()
+		json = require "util.json";
+		jp = require "util.jsonpointer";
+	end)
+	describe("resolve()", function()
+		local example;
+		setup(function()
+			example = json.decode([[{
+				"foo": ["bar", "baz"],
+				"": 0,
+				"a/b": 1,
+				"c%d": 2,
+				"e^f": 3,
+				"g|h": 4,
+				"i\\j": 5,
+				"k\"l": 6,
+				" ": 7,
+				"m~n": 8
+		 }]])
+		end)
+		it("works", function()
+			assert.same(example, jp.resolve(example, ""));
+			assert.same({ "bar", "baz" }, jp.resolve(example, "/foo"));
+			assert.same("bar", jp.resolve(example, "/foo/0"));
+			assert.same(0, jp.resolve(example, "/"));
+			assert.same(1, jp.resolve(example, "/a~1b"));
+			assert.same(2, jp.resolve(example, "/c%d"));
+			assert.same(3, jp.resolve(example, "/e^f"));
+			assert.same(4, jp.resolve(example, "/g|h"));
+			assert.same(5, jp.resolve(example, "/i\\j"));
+			assert.same(6, jp.resolve(example, "/k\"l"));
+			assert.same(7, jp.resolve(example, "/ "));
+			assert.same(8, jp.resolve(example, "/m~0n"));
+		end)
+	end)
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_jsonschema_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,108 @@
+local js = require "util.jsonschema";
+local json = require "util.json";
+local lfs = require "lfs";
+
+-- https://github.com/json-schema-org/JSON-Schema-Test-Suite.git 2.0.0-550-g88d6948
+local test_suite_dir = "spec/JSON-Schema-Test-Suite/tests/draft2020-12"
+if lfs.attributes(test_suite_dir, "mode") ~= "directory" then return end
+
+-- Tests to skip and short reason why (NYI = not yet implemented)
+local skip = {
+	["additionalProperties.json:0:2"] = "distinguishing objects from arrays",
+	["additionalProperties.json:0:5"] = "NYI",
+	["additionalProperties.json:1:0"] = "NYI",
+	["anchor.json"] = "$anchor NYI",
+	["const.json:1"] = "deepcompare",
+	["const.json:13:2"] = "IEEE 754 equality",
+	["const.json:2"] = "deepcompare",
+	["const.json:8"] = "deepcompare",
+	["const.json:9"] = "deepcompare",
+	["contains.json:0:5"] = "distinguishing objects from arrays",
+	["defs.json"] = "need built-in meta-schema",
+	["dependentRequired.json"] = "NYI",
+	["dependentSchemas.json"] = "NYI",
+	["dynamicRef.json"] = "NYI",
+	["enum.json:1:3"] = "deepcompare",
+	["id.json"] = "NYI",
+	["maxContains.json"] = "NYI",
+	["maxLength.json:0:4"] = "UTF-16",
+	["maxProperties.json"] = "NYI",
+	["minContains.json"] = "NYI",
+	["minLength.json:0:4"] = "UTF-16",
+	["minProperties.json"] = "NYI",
+	["multipleOf.json:1"] = "multiples of IEEE 754 fractions",
+	["multipleOf.json:2"] = "multiples of IEEE 754 fractions",
+	["pattern.json"] = "NYI",
+	["patternProperties.json"] = "NYI",
+	["properties.json:1:2"] = "NYI",
+	["properties.json:1:3"] = "NYI",
+	["ref.json:0:3"] = "NYI additionalProperties",
+	["ref.json:11"] = "NYI",
+	["ref.json:12:1"] = "FIXME",
+	["ref.json:13"] = "NYI",
+	["ref.json:14"] = "NYI",
+	["ref.json:15"] = "NYI",
+	["ref.json:16"] = "NYI",
+	["ref.json:17"] = "NYI",
+	["ref.json:18"] = "NYI",
+	["ref.json:19"] = "NYI",
+	["ref.json:26"] = "NYI",
+	["ref.json:27"] = "NYI",
+	["ref.json:28"] = "NYI",
+	["ref.json:3:2"] = "FIXME investigate, util.jsonpath issue?",
+	["required.json:4"] = "JavaScript specific and distinguishing objects from arrays",
+	["ref.json:6:1"] = "NYI",
+	["ref.json:20"] = "NYI",
+	["ref.json:25"] = "NYI",
+	["refRemote.json"] = "DEFINITELY NYI",
+	["required.json:0:2"] = "distinguishing objects from arrays",
+	["type.json:3:4"] = "distinguishing objects from arrays",
+	["type.json:3:6"] = "null is weird",
+	["type.json:4:3"] = "distinguishing objects from arrays",
+	["type.json:4:6"] = "null is weird",
+	["type.json:9:4"] = "null is weird",
+	["type.json:9:6"] = "null is weird",
+	["unevaluatedItems.json"] = "NYI",
+	["unevaluatedProperties.json"] = "NYI",
+	["uniqueItems.json:0:11"] = "deepcompare",
+	["uniqueItems.json:0:13"] = "deepcompare",
+	["uniqueItems.json:0:14"] = "deepcompare",
+	["uniqueItems.json:0:22"] = "deepcompare",
+	["uniqueItems.json:0:24"] = "deepcompare",
+	["uniqueItems.json:0:9"] = "deepcompare",
+	["unknownKeyword.json"] = "NYI",
+	["vocabulary.json"] = "NYI",
+};
+
+local function label(s, i)
+	return string.format("%s:%d", s, i-1);
+end
+
+describe("util.jsonschema.validate", function()
+	for test_case_file in lfs.dir(test_suite_dir) do
+		-- print(skip[test_case_file] and "do  " or "skip", test_case_file)
+		if test_case_file:sub(-5) == ".json" and not skip[test_case_file] then
+			describe(test_case_file, function()
+				local test_cases;
+				setup(function()
+					local f = assert(io.open(test_suite_dir .. "/" .. test_case_file));
+					local rawdata = assert(f:read("*a"), "failed to read " .. test_case_file)
+					test_cases = assert(json.decode(rawdata), "failed to parse " .. test_case_file)
+				end)
+				describe("tests", function()
+					for i, schema_test in ipairs(test_cases) do
+						local generic_label = label(test_case_file, i);
+						describe(schema_test.description or generic_label, function()
+							for j, test in ipairs(schema_test.tests) do
+								local specific_label = label(generic_label, j);
+								((skip[generic_label] or skip[specific_label]) and pending or it)(test.description, function()
+									assert.equal(test.valid, js.validate(schema_test.schema, test.data), specific_label .. " " .. test.description);
+								end)
+							end
+						end)
+					end
+				end)
+			end)
+		end
+	end
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_jwt_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,20 @@
+local jwt = require "util.jwt";
+
+describe("util.jwt", function ()
+	it("validates", function ()
+		local key = "secret";
+		local token = jwt.sign(key, { payload = "this" });
+		assert.string(token);
+		local ok, parsed = jwt.verify(key, token);
+		assert.truthy(ok)
+		assert.same({ payload = "this" }, parsed);
+	end);
+	it("rejects invalid", function ()
+		local key = "secret";
+		local token = jwt.sign("wrong", { payload = "this" });
+		assert.string(token);
+		local ok = jwt.verify(key, token);
+		assert.falsy(ok)
+	end);
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_paths_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,39 @@
+local sep = package.config:match("(.)\n");
+describe("util.paths", function ()
+	local paths = require "util.paths";
+	describe("#join()", function ()
+		it("returns single component as-is", function ()
+			assert.equal("foo", paths.join("foo"));
+		end);
+		it("joins paths", function ()
+			assert.equal("foo"..sep.."bar", paths.join("foo", "bar"))
+		end);
+		it("joins longer paths", function ()
+			assert.equal("foo"..sep.."bar"..sep.."baz", paths.join("foo", "bar", "baz"))
+		end);
+		it("joins even longer paths", function ()
+			assert.equal("foo"..sep.."bar"..sep.."baz"..sep.."moo", paths.join("foo", "bar", "baz", "moo"))
+		end);
+	end)
+
+	describe("#glob_to_pattern()", function ()
+		it("works", function ()
+			assert.equal("^thing.%..*$", paths.glob_to_pattern("thing?.*"))
+		end);
+	end)
+
+	describe("#resolve_relative_path()", function ()
+		it("returns absolute paths as-is", function ()
+			if sep == "/" then
+				assert.equal("/tmp/path", paths.resolve_relative_path("/run", "/tmp/path"));
+			elseif sep == "\\" then
+				assert.equal("C:\\Program Files", paths.resolve_relative_path("A:\\", "C:\\Program Files"));
+			end
+		end);
+		it("resolves relative paths", function ()
+			if sep == "/" then
+				assert.equal("/run/path", paths.resolve_relative_path("/run", "path"));
+			end
+		end);
+	end)
+end)
--- a/spec/util_promise_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_promise_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -17,7 +17,7 @@
 		p:next(cb);
 		assert.spy(cb).was_called(1);
 	end);
-	it("notifies on fulfilment of pending promises", function ()
+	it("notifies on fulfillment of pending promises", function ()
 		local r;
 		local p = promise.new(function (resolve)
 			r = resolve;
@@ -248,6 +248,30 @@
 			assert.spy(cb3).was_called(1);
 			assert.spy(cb3).was_called_with("goodbye");
 		end);
+
+		it("ordinary values", function ()
+			local p = promise.resolve()
+			local cb = spy.new(function ()
+				return "hello"
+			end);
+			local cb2 = spy.new(function () end);
+			p:next(cb):next(cb2);
+			assert.spy(cb).was_called(1);
+			assert.spy(cb2).was_called(1);
+			assert.spy(cb2).was_called_with("hello");
+		end);
+
+		it("nil", function ()
+			local p = promise.resolve()
+			local cb = spy.new(function ()
+				return
+			end);
+			local cb2 = spy.new(function () end);
+			p:next(cb):next(cb2);
+			assert.spy(cb).was_called(1);
+			assert.spy(cb2).was_called(1);
+			assert.spy(cb2).was_called_with(nil);
+		end);
 	end);
 
 	describe("race()", function ()
@@ -328,6 +352,130 @@
 			assert.spy(cb_err).was_called(1);
 			assert.equal("fail", result);
 		end);
+		it("works with non-numeric keys", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+			local p = promise.all({ [true] = p1, [false] = p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("yep");
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same({ [true] = "nope", [false] = "yep" }, result);
+		end);
+		it("passes through non-promise values", function ()
+			local r1;
+			local p1 = promise.new(function (resolve) r1 = resolve end);
+			local p = promise.all({ [true] = p1, [false] = "yep" });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same({ [true] = "nope", [false] = "yep" }, result);
+		end);
+	end);
+	describe("all_settled()", function ()
+		it("works with fulfilled promises", function ()
+			local p1, p2 = promise.resolve("yep"), promise.resolve("nope");
+			local p = promise.all_settled({ p1, p2 });
+			local result;
+			p:next(function (v)
+				result = v;
+			end);
+			assert.same({
+				{ status = "fulfilled", value = "yep" };
+				{ status = "fulfilled", value = "nope" };
+			}, result);
+		end);
+		it("works with pending promises", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+			local p = promise.all_settled({ p1, p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("yep");
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same({
+				{ status = "fulfilled", value = "nope" };
+				{ status = "fulfilled", value = "yep" };
+			}, result);
+		end);
+		it("works when some promises reject", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (_, reject) r2 = reject end);
+			local p = promise.all_settled({ p1, p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("this fails");
+			assert.spy(cb).was_called(0);
+			r1("this succeeds");
+			assert.spy(cb).was_called(1);
+			assert.same({
+				{ status = "fulfilled", value = "this succeeds" };
+				{ status = "rejected", reason = "this fails" };
+			}, result);
+		end);
+		it("works with non-numeric keys", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+			local p = promise.all_settled({ foo = p1, bar = p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("yep");
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same({
+				foo = { status = "fulfilled", value = "nope" };
+				bar = { status = "fulfilled", value = "yep" };
+			}, result);
+		end);
+		it("passes through non-promise values", function ()
+			local r1;
+			local p1 = promise.new(function (resolve) r1 = resolve end);
+			local p = promise.all_settled({ foo = p1, bar = "yep" });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same({
+				foo = { status = "fulfilled", value = "nope" };
+				bar = "yep";
+			}, result);
+		end);
 	end);
 	describe("catch()", function ()
 		it("works", function ()
@@ -344,6 +492,32 @@
 			assert.same({ foo = true }, result);
 		end);
 	end);
+	describe("join()", function ()
+		it("works", function ()
+			local r1, r2;
+			local res1, res2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+
+			local p = promise.join(function (_res1, _res2)
+				res1, res2 = _res1, _res2;
+				return promise.resolve("works");
+			end, p1, p2);
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("yep");
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same("works", result);
+			assert.equals("nope", res1);
+			assert.equals("yep", res2);
+		end);
+	end);
 	it("promises may be resolved by other promises", function ()
 		local r1, r2;
 		local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
@@ -494,4 +668,18 @@
 			assert.spy(on_rejected).was_called_with(test_error);
 		end);
 	end);
+	describe("set_nexttick()", function ()
+		it("works", function ()
+			local next_tick = spy.new(function (f)
+				f();
+			end)
+			local cb = spy.new(function () end);
+			promise.set_nexttick(next_tick);
+			promise.new(function (y, _)
+				y("okay");
+			end):next(cb);
+			assert.spy(next_tick).was.called();
+			assert.spy(cb).was.called_with("okay");
+		end);
+	end)
 end);
--- a/spec/util_pubsub_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_pubsub_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -101,13 +101,14 @@
 			assert(service:publish("node", true, "1", "item 1", { myoption = true }));
 
 			local ok, config = assert(service:get_node_config("node", true));
+			assert.truthy(ok);
 			assert.equals(true, config.myoption);
 		end);
 
 		it("fails to publish to a node with differing config", function ()
 			local ok, err = service:publish("node", true, "1", "item 2", { myoption = false });
 			assert.falsy(ok);
-			assert.equals("precondition-not-met", err);
+			assert.equals("precondition-not-met", err.pubsub_condition);
 		end);
 
 		it("allows to publish to a node with differing config when only defaults are suggested", function ()
@@ -168,6 +169,26 @@
 			}, ret);
 		end);
 
+		it("has a default max_items", function ()
+			assert.truthy(service.config.max_items);
+		end)
+
+		it("changes max_items to max", function ()
+			assert.truthy(service:set_node_config("node", true, { max_items = "max" }));
+		end);
+
+		it("publishes some more items", function()
+			for i = 4, service.config.max_items + 5 do
+				assert.truthy(service:publish("node", true, tostring(i), "item " .. tostring(i)));
+			end
+		end);
+
+		it("should still return only two items", function ()
+			local ok, ret = service:get_items("node", true);
+			assert.truthy(ok);
+			assert.same(service.config.max_items, #ret);
+		end);
+
 	end);
 
 	describe("the thing", function ()
@@ -229,6 +250,7 @@
 			end);
 			it("should be the default", function ()
 				local ok, config = service:get_node_config("test", true);
+				assert.truthy(ok);
 				assert.equal("open", config.access_model);
 			end);
 			it("should allow anyone to subscribe", function ()
@@ -250,6 +272,7 @@
 			end);
 			it("should be present in the configuration", function ()
 				local ok, config = service:get_node_config("test", true);
+				assert.truthy(ok);
 				assert.equal("whitelist", config.access_model);
 			end);
 			it("should not allow anyone to subscribe", function ()
@@ -294,6 +317,7 @@
 			end);
 			it("should be the default", function ()
 				local ok, config = service:get_node_config("test", true);
+				assert.truthy(ok);
 				assert.equal("publishers", config.publish_model);
 			end);
 			it("should not allow anyone to publish", function ()
@@ -304,6 +328,7 @@
 			end);
 			it("should allow publishers to publish", function ()
 				assert(service:set_affiliation("test", true, "mypublisher", "publisher"));
+				-- luacheck: ignore 211/err
 				local ok, err = service:publish("test", "mypublisher", "item1", "foo");
 				assert.is_true(ok);
 			end);
@@ -342,6 +367,7 @@
 			end);
 			it("should allow publishers to publish without a subscription", function ()
 				assert(service:set_affiliation("test", true, "mypublisher", "publisher"));
+				-- luacheck: ignore 211/err
 				local ok, err = service:publish("test", "mypublisher", "item1", "foo");
 				assert.is_true(ok);
 			end);
@@ -477,4 +503,106 @@
 
 	end);
 
+	describe("subscriber filter", function ()
+		it("works", function ()
+			local filter = spy.new(function (subs) -- luacheck: ignore 212/subs
+				return {["modified"] = true};
+			end);
+			local broadcaster = spy.new(function (notif_type, node_name, subscribers, item) -- luacheck: ignore 212
+			end);
+			local service = pubsub.new({
+					subscriber_filter = filter;
+					broadcaster = broadcaster;
+				});
+
+			local ok = service:create("node", true);
+			assert.truthy(ok);
+
+			local ok = service:add_subscription("node", true, "someone");
+			assert.truthy(ok);
+
+			local ok = service:publish("node", true, "1", "item");
+			assert.truthy(ok);
+			-- TODO how to match table arguments?
+			assert.spy(filter).was_called();
+			assert.spy(broadcaster).was_called();
+		end);
+	end);
+
+	describe("persist_items", function()
+		it("can be disabled", function()
+			local broadcaster = spy.new(function(notif_type, node_name, subscribers, item) -- luacheck: ignore 212
+			end);
+			local service = pubsub.new { node_defaults = { persist_items = false }, broadcaster = broadcaster }
+
+			local ok = service:create("node", true)
+			assert.truthy(ok);
+
+			local ok = service:publish("node", true, "1", "item");
+			assert.truthy(ok);
+			assert.spy(broadcaster).was_called();
+
+			local ok, items = service:get_items("node", true);
+			assert.not_truthy(ok);
+			assert.equal(items, "persistent-items-unsupported");
+		end);
+
+	end)
+
+	describe("max_items", function ()
+		it("works", function ()
+			local service = pubsub.new { };
+
+			local ok = service:create("node", true)
+			assert.truthy(ok);
+
+			for i = 1, 20 do
+				assert.truthy(service:publish("node", true, "item"..tostring(i), "data"..tostring(i)));
+			end
+
+			do
+				local ok, items = service:get_items("node", true, nil, { max = 3 });
+				assert.truthy(ok, items);
+				assert.equal(3, #items);
+				assert.same({
+						"item20",
+						"item19",
+						"item18",
+						item20 = "data20",
+						item19 = "data19",
+						item18 = "data18",
+					}, items, "items should be ordered by oldest first");
+			end
+
+			do
+				local ok, items = service:get_items("node", true, nil, { max = 10 });
+				assert.truthy(ok, items);
+				assert.equal(10, #items);
+				assert.same({
+						"item20",
+						"item19",
+						"item18",
+						"item17",
+						"item16",
+						"item15",
+						"item14",
+						"item13",
+						"item12",
+						"item11",
+						item20 = "data20",
+						item19 = "data19",
+						item18 = "data18",
+						item17 = "data17",
+						item16 = "data16",
+						item15 = "data15",
+						item14 = "data14",
+						item13 = "data13",
+						item12 = "data12",
+						item11 = "data11",
+					}, items, "items should be ordered by oldest first");
+			end
+
+		end);
+
+	end)
 end);
--- a/spec/util_queue_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_queue_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -100,4 +100,41 @@
 
 		end);
 	end);
+	describe("consume()", function ()
+		it("should work", function ()
+			local q = queue.new(10);
+			for i = 1, 5 do
+				q:push(i);
+			end
+			local c = 0;
+			for i in q:consume() do
+				assert(i == c + 1);
+				assert(q:count() == (5-i));
+				c = i;
+			end
+		end);
+
+		it("should work even if items are pushed in the loop", function ()
+			local q = queue.new(10);
+			for i = 1, 5 do
+				q:push(i);
+			end
+			local c = 0;
+			for i in q:consume() do
+				assert(i == c + 1);
+				if c < 3 then
+					assert(q:count() == (5-i));
+				else
+					assert(q:count() == (6-i));
+				end
+
+				c = i;
+
+				if c == 3 then
+					q:push(6);
+				end
+			end
+			assert.equal(c, 6);
+		end);
+	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_ringbuffer_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,103 @@
+local rb = require "util.ringbuffer";
+describe("util.ringbuffer", function ()
+	describe("#new", function ()
+		it("has a constructor", function ()
+			assert.Function(rb.new);
+		end);
+		it("can be created", function ()
+			assert.truthy(rb.new());
+		end);
+		it("won't create an empty buffer", function ()
+			assert.has_error(function ()
+				rb.new(0);
+			end);
+		end);
+		it("won't create a negatively sized buffer", function ()
+			assert.has_error(function ()
+				rb.new(-1);
+			end);
+		end);
+	end);
+	describe(":write", function ()
+		local b = rb.new();
+		it("works", function ()
+			assert.truthy(b:write("hi"));
+		end);
+	end);
+
+	describe(":discard", function ()
+		local b = rb.new();
+		it("works", function ()
+			assert.truthy(b:write("hello world"));
+			assert.truthy(b:discard(6));
+			assert.equal(5, #b);
+			assert.equal("world", b:read(5));
+		end);
+	end);
+
+	describe(":sub", function ()
+		-- Helper function to compare buffer:sub() with string:sub()
+		local function test_sub(b, x, y)
+			local s = b:read(#b, true);
+			local string_result, buffer_result = s:sub(x, y), b:sub(x, y);
+			assert.equals(string_result, buffer_result, ("buffer:sub(%d, %s) does not match string:sub()"):format(x, y and ("%d"):format(y) or "nil"));
+		end
+
+		it("works", function ()
+			local b = rb.new();
+			assert.truthy(b:write("hello world"));
+			assert.equals("hello", b:sub(1, 5));
+		end);
+
+		it("supports optional end parameter", function ()
+			local b = rb.new();
+			assert.truthy(b:write("hello world"));
+			assert.equals("hello world", b:sub(1));
+			assert.equals("world", b:sub(-5));
+		end);
+
+		it("is equivalent to string:sub", function ()
+			local b = rb.new(6);
+			assert.truthy(b:write("foobar"));
+			b:read(3);
+			b:write("foo");
+			for i = -13, 13 do
+				for j = -13, 13 do
+					test_sub(b, i, j);
+				end
+			end
+		end);
+	end);
+
+	describe(":byte", function ()
+		-- Helper function to compare buffer:byte() with string:byte()
+		local function test_byte(b, x, y)
+			local s = b:read(#b, true);
+			local string_result, buffer_result = {s:byte(x, y)}, {b:byte(x, y)};
+			assert.same(string_result, buffer_result, ("buffer:byte(%d, %s) does not match string:byte()"):format(x, y and ("%d"):format(y) or "nil"));
+		end
+
+		it("is equivalent to string:byte", function ()
+			local b = rb.new(6);
+			assert.truthy(b:write("foobar"));
+			b:read(3);
+			b:write("foo");
+			test_byte(b, 1);
+			test_byte(b, 3);
+			test_byte(b, -1);
+			test_byte(b, -3);
+			for i = -13, 13 do
+				for j = -13, 13 do
+					test_byte(b, i, j);
+				end
+			end
+		end);
+
+		it("works with characters > 127", function ()
+			local b = rb.new();
+			b:write(string.char(0, 140));
+			local r = { b:byte(1, 2) };
+			assert.same({ 0, 140 }, r);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_rsm_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,132 @@
+local rsm = require "util.rsm";
+local xml = require "util.xml";
+
+local function strip(s)
+	return (s:gsub(">%s+<", "><"));
+end
+
+describe("util.rsm", function ()
+	describe("parse", function ()
+		it("works", function ()
+			local test = xml.parse(strip([[
+				<set xmlns='http://jabber.org/protocol/rsm'>
+					<max>10</max>
+				</set>
+				]]));
+			assert.same({ max = 10 }, rsm.parse(test));
+		end);
+
+		it("works", function ()
+			local test = xml.parse(strip([[
+				<set xmlns='http://jabber.org/protocol/rsm'>
+					<first index='0'>saint@example.org</first>
+					<last>peterpan@neverland.lit</last>
+					<count>800</count>
+				</set>
+				]]));
+			assert.same({ first = { index = 0, "saint@example.org" }, last = "peterpan@neverland.lit", count = 800 }, rsm.parse(test));
+		end);
+
+		it("works", function ()
+			local test = xml.parse(strip([[
+				<set xmlns='http://jabber.org/protocol/rsm'>
+					<max>10</max>
+					<before>peter@pixyland.org</before>
+				</set>
+				]]));
+			assert.same({ max = 10, before = "peter@pixyland.org" }, rsm.parse(test));
+		end);
+
+		it("all fields works", function()
+			local test = assert(xml.parse(strip([[
+				<set xmlns='http://jabber.org/protocol/rsm'>
+					<after>a</after>
+					<before>b</before>
+					<count>10</count>
+					<first index='1'>f</first>
+					<index>5</index>
+					<last>z</last>
+					<max>100</max>
+				</set>
+				]])));
+			assert.same({
+				after = "a";
+				before = "b";
+				count = 10;
+				first = {index = 1; "f"};
+				index = 5;
+				last = "z";
+				max = 100;
+			}, rsm.parse(test));
+		end);
+	end);
+
+	describe("generate", function ()
+		it("works", function ()
+			local test = xml.parse(strip([[
+				<set xmlns='http://jabber.org/protocol/rsm'>
+					<max>10</max>
+				</set>
+				]]));
+			local res = rsm.generate({ max = 10 });
+			assert.same(test:get_child_text("max"), res:get_child_text("max"));
+		end);
+
+		it("works", function ()
+			local test = xml.parse(strip([[
+				<set xmlns='http://jabber.org/protocol/rsm'>
+					<first index='0'>saint@example.org</first>
+					<last>peterpan@neverland.lit</last>
+					<count>800</count>
+				</set>
+				]]));
+			local res = rsm.generate({ first = { index = 0, "saint@example.org" }, last = "peterpan@neverland.lit", count = 800 });
+			assert.same(test:get_child("first").attr.index, res:get_child("first").attr.index);
+			assert.same(test:get_child_text("first"), res:get_child_text("first"));
+			assert.same(test:get_child_text("last"), res:get_child_text("last"));
+			assert.same(test:get_child_text("count"), res:get_child_text("count"));
+		end);
+
+		it("works", function ()
+			local test = xml.parse(strip([[
+			<set xmlns='http://jabber.org/protocol/rsm'>
+				<max>10</max>
+				<before>peter@pixyland.org</before>
+			</set>
+			]]));
+			local res = rsm.generate({ max = 10, before = "peter@pixyland.org" });
+			assert.same(test:get_child_text("max"), res:get_child_text("max"));
+			assert.same(test:get_child_text("before"), res:get_child_text("before"));
+		end);
+
+		it("handles floats", function ()
+			local r1 = rsm.generate({ max = 10.0, count = 100.0, first = { index = 1.0, "foo" } });
+			assert.equal("10", r1:get_child_text("max"));
+			assert.equal("100", r1:get_child_text("count"));
+			assert.equal("1", r1:get_child("first").attr.index);
+		end);
+
+
+		it("all fields works", function ()
+			local res = rsm.generate({
+					after = "a";
+					before = "b";
+					count = 10;
+					first = {index = 1; "f"};
+					index = 5;
+					last = "z";
+					max = 100;
+				});
+			assert.equal("a", res:get_child_text("after"));
+			assert.equal("b", res:get_child_text("before"));
+			assert.equal("10", res:get_child_text("count"));
+			assert.equal("f", res:get_child_text("first"));
+			assert.equal("1", res:get_child("first").attr.index);
+			assert.equal("5", res:get_child_text("index"));
+			assert.equal("z", res:get_child_text("last"));
+			assert.equal("100", res:get_child_text("max"));
+		end);
+	end);
+
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_sasl_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,43 @@
+local sasl = require "util.sasl";
+
+-- profile * mechanism
+-- callbacks could use spies instead
+
+describe("util.sasl", function ()
+	describe("plain_test profile", function ()
+		local profile = {
+			plain_test = function (_, username, password, realm)
+				assert.equals("user", username)
+				assert.equals("pencil", password)
+				assert.equals("sasl.test", realm)
+				return true, true;
+			end;
+		};
+		it("works with PLAIN", function ()
+			local plain = sasl.new("sasl.test", profile);
+			assert.truthy(plain:select("PLAIN"));
+			assert.truthy(plain:process("\000user\000pencil"));
+			assert.equals("user", plain.username);
+		end);
+	end);
+
+	describe("plain profile", function ()
+		local profile = {
+			plain = function (_, username, realm)
+				assert.equals("user", username)
+				assert.equals("sasl.test", realm)
+				return "pencil", true;
+			end;
+		};
+
+		it("works with PLAIN", function ()
+			local plain = sasl.new("sasl.test", profile);
+			assert.truthy(plain:select("PLAIN"));
+			assert.truthy(plain:process("\000user\000pencil"));
+			assert.equals("user", plain.username);
+		end);
+
+		-- TODO SCRAM
+	end);
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_smqueue_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,81 @@
+describe("util.smqueue", function()
+
+	local smqueue
+	setup(function() smqueue = require "util.smqueue"; end)
+
+	describe("#new()", function()
+		it("should work", function()
+			local q = smqueue.new(10);
+			assert.truthy(q);
+		end)
+	end)
+
+	describe("#push()", function()
+		it("should allow pushing many items", function()
+			local q = smqueue.new(10);
+			for i = 1, 20 do q:push(i); end
+			assert.equal(20, q:count_unacked());
+		end)
+	end)
+
+	describe("#resumable()", function()
+		it("returns true while the queue is small", function()
+			local q = smqueue.new(10);
+			for i = 1, 10 do q:push(i); end
+			assert.truthy(q:resumable());
+			q:push(11);
+			assert.falsy(q:resumable());
+		end)
+	end)
+
+	describe("#ack", function()
+		it("allows removing items", function()
+			local q = smqueue.new(10);
+			for i = 1, 10 do q:push(i); end
+			assert.same({ 1; 2; 3 }, q:ack(3));
+			assert.same({ 4; 5; 6 }, q:ack(6));
+			assert.falsy(q:ack(3), "can't go backwards")
+			assert.falsy(q:ack(100), "can't ack too many")
+			for i = 11, 20 do q:push(i); end
+			assert.same({ 11; 12 }, q:ack(12), "items are dropped");
+		end)
+	end)
+
+	describe("#resume", function()
+		it("iterates over current items", function()
+			local q = smqueue.new(10);
+			for i = 1, 12 do q:push(i); end
+			assert.same({ 3; 4; 5; 6 }, q:ack(6));
+			assert.truthy(q:resumable());
+			local resume = {}
+			for _, i in q:resume() do resume[i] = true end
+			assert.same({ [7] = true; [8] = true; [9] = true; [10] = true; [11] = true; [12] = true }, resume);
+		end)
+	end)
+
+	describe("#table", function ()
+		it("produces a compat layer", function ()
+			local q = smqueue.new(10);
+			for i = 1,10 do q:push(i); end
+			do
+				local t = q:table();
+				assert.same({ 1; 2; 3; 4; 5; 6; 7; 8; 9; 10 }, t);
+			end
+			do
+				for i = 11,20 do q:push(i); end
+				local t = q:table();
+				assert.same({ 11; 12; 13; 14; 15; 16; 17; 18; 19; 20 }, t);
+			end
+			do
+				q:ack(15);
+				local t = q:table();
+				assert.same({ 16; 17; 18; 19; 20 }, t);
+			end
+			do
+				q:ack(20);
+				local t = q:table();
+				assert.same({}, t);
+			end
+		end)
+	end)
+end);
--- a/spec/util_stanza_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_stanza_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,5 +1,6 @@
 
 local st = require "util.stanza";
+local errors = require "util.error";
 
 describe("util.stanza", function()
 	describe("#preserialize()", function()
@@ -84,6 +85,31 @@
 			assert.same(st.stanza("foo"):text(nil), s_control);
 			assert.same(st.stanza("foo"):text(""), s_control);
 		end);
+		it("validates names", function ()
+			assert.has_error_match(function ()
+				st.stanza("invalid\0name");
+			end, "invalid tag name:")
+			assert.has_error_match(function ()
+				st.stanza("name", { ["foo\1\2\3bar"] = "baz" });
+			end, "invalid attribute name: contains control characters")
+			assert.has_error_match(function ()
+				st.stanza("name", { ["foo"] = "baz\1\2\3\255moo" });
+			end, "invalid attribute value: contains control characters")
+		end)
+		it("validates types", function ()
+			assert.has_error_match(function ()
+				st.stanza(1);
+			end, "invalid tag name: expected string, got number")
+			assert.has_error_match(function ()
+				st.stanza("name", "string");
+			end, "invalid attributes: expected table, got string")
+			assert.has_error_match(function ()
+				st.stanza("name",{1});
+			end, "invalid attribute name: expected string, got number")
+			assert.has_error_match(function ()
+				st.stanza("name",{foo=1});
+			end, "invalid attribute value: expected string, got number")
+		end)
 	end);
 
 	describe("#message()", function()
@@ -95,19 +121,30 @@
 
 	describe("#iq()", function()
 		it("should create an iq stanza", function()
-			local i = st.iq({ id = "foo" });
+			local i = st.iq({ type = "get", id = "foo" });
 			assert.are.equal("iq", i.name);
 			assert.are.equal("foo", i.attr.id);
+			assert.are.equal("get", i.attr.type);
 		end);
 
+		it("should reject stanzas with no attributes", function ()
+			assert.has.error_match(function ()
+				st.iq();
+			end, "attributes");
+		end);
+
+
 		it("should reject stanzas with no id", function ()
 			assert.has.error_match(function ()
-				st.iq();
+				st.iq({ type = "get" });
 			end, "id attribute");
+		end);
 
+		it("should reject stanzas with no type", function ()
 			assert.has.error_match(function ()
-				st.iq({ foo = "bar" });
-			end, "id attribute");
+				st.iq({ id = "foo" });
+			end, "type attribute");
+
 		end);
 	end);
 
@@ -159,6 +196,19 @@
 			assert.are.equal(r.attr.type, "result");
 			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
 		end);
+
+		it("should reject not-stanzas", function ()
+			assert.has.error_match(function ()
+				st.reply(not "a stanza");
+			end, "expected stanza");
+		end);
+
+		it("should reject not-stanzas", function ()
+			assert.has.error_match(function ()
+				st.reply({name="x"});
+			end, "expected stanza");
+		end);
+
 	end);
 
 	describe("#error_reply()", function()
@@ -167,13 +217,14 @@
 			local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
 				:tag("child1");
 			-- Make reply stanza
-			local r = st.error_reply(s, "cancel", "service-unavailable");
+			local r = st.error_reply(s, "cancel", "service-unavailable", nil, "host");
 			assert.are.equal(r.name, s.name);
 			assert.are.equal(r.id, s.id);
 			assert.are.equal(r.attr.to, s.attr.from);
 			assert.are.equal(r.attr.from, s.attr.to);
 			assert.are.equal(#r.tags, 1);
 			assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
+			assert.are.equal(r.tags[1].attr.by, "host");
 		end);
 
 		it("should work for <iq get>", function()
@@ -190,8 +241,79 @@
 			assert.are.equal(#r.tags, 1);
 			assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
 		end);
+
+		it("should reject not-stanzas", function ()
+			assert.has.error_match(function ()
+				st.error_reply(not "a stanza", "modify", "bad-request");
+			end, "expected stanza");
+		end);
+
+		it("should reject stanzas of type error", function ()
+			assert.has.error_match(function ()
+				st.error_reply(st.message({type="error"}), "cancel", "conflict");
+			end, "got stanza of type error");
+			assert.has.error_match(function ()
+				st.error_reply(st.error_reply(st.message({type="chat"}), "modify", "forbidden"), "cancel", "service-unavailable");
+			end, "got stanza of type error");
+		end);
+
+		describe("util.error integration", function ()
+		it("should accept util.error objects", function ()
+			local s = st.message({ to = "touser", from = "fromuser", id = "123", type = "chat" }, "Hello");
+			local e = errors.new({ type = "modify", condition = "not-acceptable", text = "Bork bork bork" }, { by = "this.test" });
+			local r = st.error_reply(s, e);
+
+			assert.are.equal(r.name, s.name);
+			assert.are.equal(r.id, s.id);
+			assert.are.equal(r.attr.to, s.attr.from);
+			assert.are.equal(r.attr.from, s.attr.to);
+			assert.are.equal(r.attr.type, "error");
+			assert.are.equal(r.tags[1].name, "error");
+			assert.are.equal(r.tags[1].attr.type, e.type);
+			assert.are.equal(r.tags[1].tags[1].name, e.condition);
+			assert.are.equal(r.tags[1].tags[2]:get_text(), e.text);
+			assert.are.equal("this.test", r.tags[1].attr.by);
+		end);
+
+		it("should accept util.error objects with an URI", function ()
+			local s = st.message({ to = "touser", from = "fromuser", id = "123", type = "chat" }, "Hello");
+			local gone = errors.new({ condition = "gone", extra = { uri = "file:///dev/null" } })
+			local gonner = st.error_reply(s, gone);
+			assert.are.equal("gone", gonner.tags[1].tags[1].name);
+			assert.are.equal("file:///dev/null", gonner.tags[1].tags[1][1]);
+		end);
+
+		it("should accept util.error objects with application specific error", function ()
+			local s = st.message({ to = "touser", from = "fromuser", id = "123", type = "chat" }, "Hello");
+			local e = errors.new({ condition = "internal-server-error", text = "Namespaced thing happened",
+				extra = {namespace="xmpp:example.test", condition="this-happened"} })
+			local r = st.error_reply(s, e);
+			assert.are.equal("xmpp:example.test", r.tags[1].tags[3].attr.xmlns);
+			assert.are.equal("this-happened", r.tags[1].tags[3].name);
+
+			local e2 = errors.new({ condition = "internal-server-error", text = "Namespaced thing happened",
+				extra = {tag=st.stanza("that-happened", { xmlns = "xmpp:example.test", ["another-attribute"] = "here" })} })
+			local r2 = st.error_reply(s, e2);
+			assert.are.equal("xmpp:example.test", r2.tags[1].tags[3].attr.xmlns);
+			assert.are.equal("that-happened", r2.tags[1].tags[3].name);
+			assert.are.equal("here", r2.tags[1].tags[3].attr["another-attribute"]);
+		end);
+		end);
 	end);
 
+	describe("#get_error()", function ()
+		describe("basics", function ()
+			local s = st.message();
+			local e = st.error_reply(s, "cancel", "not-acceptable", "UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!")
+				:tag("dungeon", { xmlns = "urn:uuid:c9026187-5b05-4e70-b265-c3b6338a7d0f", period="1000000years"});
+			local typ, cond, text, extra = e:get_error();
+			assert.equal("cancel", typ);
+			assert.equal("not-acceptable", cond);
+			assert.equal("UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!", text);
+			assert.not_nil(extra)
+		end)
+	end)
+
 	describe("should reject #invalid", function ()
 		local invalid_names = {
 			["empty string"] = "", ["characters"] = "<>";
@@ -358,6 +480,26 @@
 		end);
 	end);
 
+	describe("get_child_with_attr", function ()
+		local s = st.message({ type = "chat" })
+			:text_tag("body", "Hello world", { ["xml:lang"] = "en" })
+			:text_tag("body", "Bonjour le monde", { ["xml:lang"] = "fr" })
+			:text_tag("body", "Hallo Welt", { ["xml:lang"] = "de" })
+
+		it("works", function ()
+			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "en"):get_text(), "Hello world");
+			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "de"):get_text(), "Hallo Welt");
+			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "fr"):get_text(), "Bonjour le monde");
+			assert.is_nil(s:get_child_with_attr("body", nil, "xml:lang", "FR"));
+			assert.is_nil(s:get_child_with_attr("body", nil, "xml:lang", "es"));
+		end);
+
+		it("supports normalization", function ()
+			assert.equal(s:get_child_with_attr("body", nil, "xml:lang", "EN", string.upper):get_text(), "Hello world");
+			assert.is_nil(s:get_child_with_attr("body", nil, "xml:lang", "ES", string.upper));
+		end);
+	end);
+
 	describe("#clone", function ()
 		it("works", function ()
 			local s = st.message({type="chat"}, "Hello"):reset();
@@ -371,4 +513,55 @@
 			end);
 		end);
 	end);
+
+	describe("top_tag", function ()
+		local xml_parse = require "util.xml".parse;
+		it("works", function ()
+			local s = st.message({type="chat"}, "Hello");
+			local top_tag = s:top_tag();
+			assert.is_string(top_tag);
+			assert.not_equal("/>", top_tag:sub(-2, -1));
+			assert.equal(">", top_tag:sub(-1, -1));
+			local s2 = xml_parse(top_tag.."</message>");
+			assert(st.is_stanza(s2));
+			assert.equal("message", s2.name);
+			assert.equal(0, #s2);
+			assert.equal(0, #s2.tags);
+			assert.equal("chat", s2.attr.type);
+		end);
+
+		it("works with namespaced attributes", function ()
+			local s = xml_parse[[<message foo:bar='true' xmlns:foo='my-awesome-ns'/>]];
+			local top_tag = s:top_tag();
+			assert.is_string(top_tag);
+			assert.not_equal("/>", top_tag:sub(-2, -1));
+			assert.equal(">", top_tag:sub(-1, -1));
+			local s2 = xml_parse(top_tag.."</message>");
+			assert(st.is_stanza(s2));
+			assert.equal("message", s2.name);
+			assert.equal(0, #s2);
+			assert.equal(0, #s2.tags);
+			assert.equal("true", s2.attr["my-awesome-ns\1bar"]);
+		end);
+	end);
+
+	describe("indent", function ()
+		local s = st.stanza("foo"):text("\n"):tag("bar"):tag("baz"):up():text_tag("cow", "moo");
+		assert.equal("<foo>\n\t<bar>\n\t\t<baz/>\n\t\t<cow>moo</cow>\n\t</bar>\n</foo>", tostring(s:indent()));
+		assert.equal("<foo>\n  <bar>\n    <baz/>\n    <cow>moo</cow>\n  </bar>\n</foo>", tostring(s:indent(1, "  ")));
+		assert.equal("<foo>\n\t\t<bar>\n\t\t\t<baz/>\n\t\t\t<cow>moo</cow>\n\t\t</bar>\n\t</foo>", tostring(s:indent(2, "\t")));
+	end);
+
+	describe("find", function()
+		it("works", function()
+			local s = st.stanza("root", { attr = "value" }):tag("child",
+				{ xmlns = "urn:example:not:same"; childattr = "thisvalue" }):text_tag("nested", "text"):reset();
+			assert.equal("value", s:find("@attr"), "finds attr")
+			assert.equal(s:get_child("child", "urn:example:not:same"), s:find("{urn:example:not:same}child"),
+				"equivalent to get_child")
+			assert.equal("thisvalue", s:find("{urn:example:not:same}child@childattr"), "finds child attr")
+			assert.equal("text", s:find("{urn:example:not:same}child/nested#"), "finds nested text")
+			assert.is_nil(s:find("child"), "respects namespaces")
+		end);
+	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_table_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,17 @@
+local u_table = require "util.table";
+describe("util.table", function ()
+	describe("create()", function ()
+		it("works", function ()
+			-- Can't test the allocated sizes of the table, so what you gonna do?
+			assert.is.table(u_table.create(1,1));
+		end);
+	end);
+
+	describe("pack()", function ()
+		it("works", function ()
+			assert.same({ "lorem", "ipsum", "dolor", "sit", "amet", n = 5 }, u_table.pack("lorem", "ipsum", "dolor", "sit", "amet"));
+		end);
+	end);
+end);
+
+
--- a/spec/util_throttle_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_throttle_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -88,7 +88,7 @@
 				later(0.1);
 				a:update();
 			end
-			assert(math.abs(a.balance - 1) < 0.0001); -- incremental updates cause rouding errors
+			assert(math.abs(a.balance - 1) < 0.0001); -- incremental updates cause rounding errors
 		end);
 	end);
 
--- a/spec/util_xml_spec.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/spec/util_xml_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -42,6 +42,13 @@
 			assert.falsy(ok);
 		end);
 
+		it("should allow processing instructions if asked nicely", function()
+			local x = "<?xml-stylesheet href='make-fancy.xsl'?><foo/>";
+			local stanza = xml.parse(x, {allow_processing_instructions = true});
+			assert.truthy(stanza);
+			assert.are.equal(stanza.name, "foo");
+		end);
+
 		it("should allow an xml declaration", function()
 			local x = "<?xml version='1.0'?><foo/>";
 			local stanza = xml.parse(x);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/module.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,145 @@
+local st = require"util.stanza"
+
+global record moduleapi
+	get_name : function (moduleapi) : string
+	get_host : function (moduleapi) : string
+	enum host_type
+		"global"
+		"local"
+		"component"
+	end
+	get_host_type : function (moduleapi) : host_type
+	set_global : function (moduleapi)
+	add_feature : function (moduleapi, string)
+	add_identity : function (moduleapi, string, string, string) -- TODO enum?
+	add_extension : function (moduleapi, st.stanza_t)
+	fire_event : function (moduleapi, string, any) : any
+	type handler = function (any) : any
+	record util_events
+		-- TODO import def
+	end
+	hook_object_event : function (moduleapi, util_events, string, handler, number)
+	unhook_object_event : function (moduleapi, util_events, string, handler)
+	hook : function (moduleapi, string, handler, number)
+	hook_global : function (moduleapi, string, handler, number)
+	hook_tag : function (moduleapi, string, string, handler, number)
+	unhook : function (moduleapi, string, handler)
+	wrap_object_event : function (moduleapi, util_events, string, handler)
+	wrap_event : function (moduleapi, string, handler)
+	wrap_global : function (moduleapi, string, handler)
+	require : function (moduleapi, string) : table
+	depends : function (moduleapi, string) : table
+	shared : function (moduleapi, string) : table
+	type config_getter = function<A> (moduleapi, string, A) : A
+	get_option : config_getter<any>
+	get_option_scalar : config_getter<nil | boolean | number | string>
+	get_option_string : config_getter<string>
+	get_option_number : config_getter<number>
+	get_option_boolean : config_getter<boolean>
+	record util_array
+		-- TODO import def
+		{ any }
+	end
+	get_option_array : config_getter<util_array>
+	record util_set
+		-- TODO import def
+		_items : { any : boolean }
+	end
+	get_option_set : function (moduleapi, string, { any }) : util_set
+	get_option_inherited_set : function (moduleapi, string, { any }) : util_set
+	get_option_path : function (moduleapi, string, string, string) : string
+	context : function (moduleapi, string) : moduleapi
+	add_item : function (moduleapi, string, any)
+	remove_item : function (moduleapi, string, any)
+	get_host_items : function (moduleapi, string) : { any }
+	handle_items : function (moduleapi, string, handler, handler, boolean)
+	provides : function (moduleapi, string, table)
+	record util_session
+		-- TODO import def
+		send : function ( st.stanza_t | string )
+	end
+	send : function (moduleapi, st.stanza_t, util_session)
+	send_iq : function (moduleapi, st.stanza_t, util_session, number)
+	broadcast : function (moduleapi, { string }, st.stanza_t, function)
+	type timer_callback = function (number, ... : any) : number
+	add_timer : function (moduleapi, number, timer_callback, ... : any)
+	get_directory : function (moduleapi) : string
+	enum file_mode
+		"r" "w" "a" "r+" "w+" "a+"
+	end
+	load_resource : function (moduleapi, string, file_mode) : FILE
+	enum store_type
+		"keyval"
+		"map"
+		"archive"
+	end
+	open_store : function (moduleapi, string, store_type)
+	enum stat_type
+		"amount"
+		"counter"
+		"rate"
+		"distribution"
+		"sizes"
+		"times"
+	end
+	record stats_conf
+		initial : number
+		units : string
+		type : string
+	end
+	measure : function (moduleapi, string, stat_type, stats_conf)
+	measure_object_event : function (moduleapi, util_events, string, string)
+	measure_event : function (moduleapi, string, string)
+	measure_global_event : function (moduleapi, string, string)
+	enum status_type
+		"error"
+		"warn"
+		"info"
+		"core"
+	end
+	set_status : function (moduleapi, status_type, string, boolean)
+	enum log_level
+		"debug"
+		"info"
+		"warn"
+		"error"
+	end
+	log_status : function (moduleapi, log_level, string, ... : any)
+	get_status : function (moduleapi) : status_type, string, number
+
+	-- added by modulemanager
+	name : string
+	host : string
+	_log : function (log_level, string, ... : any)
+	log : function (moduleapi, log_level, string, ... : any)
+	reloading : boolean
+	saved_state : any
+	record module_environment
+		module : moduleapi
+	end
+	environment : module_environment
+	path : string
+	resource_path : string
+
+	-- methods the module can add
+	load : function ()
+	add_host : function (moduleapi)
+	save : function () : any
+	restore : function (any)
+	unload : function ()
+end
+
+global module : moduleapi
+
+global record common_event
+	stanza : st.stanza_t
+	record origin
+		send : function (st.stanza_t)
+	end
+end
+
+global record prosody
+	version : string
+end
+
+return module
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/plugins/mod_cron.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,106 @@
+module:set_global();
+
+local async = require "util.async";
+local datetime = require "util.datetime";
+
+local record map_store<K,V>
+	-- TODO move to somewhere sensible
+	get : function (map_store<K,V>, string, K) : V
+	set : function (map_store<K,V>, string, K, V)
+end
+
+local enum frequency
+	"hourly"
+	"daily"
+	"weekly"
+end
+
+local record task_spec
+	id : string -- unique id
+	name : string -- name or short description
+	when : frequency
+	last : integer
+	run : function (task_spec, integer)
+	save : function (task_spec, integer)
+end
+
+local record task_event
+	source : module
+	item : task_spec
+end
+
+local periods : { frequency : integer } = { hourly = 3600, daily = 86400, weekly = 7*86400 }
+
+local active_hosts : { string : boolean } = {  }
+
+function module.add_host(host_module : moduleapi)
+
+	local last_run_times = host_module:open_store("cron", "map") as map_store<string,integer>;
+	active_hosts[host_module.host] = true;
+
+	local function save_task(task : task_spec, started_at : integer)
+		last_run_times:set(nil, task.id, started_at);
+	end
+
+	local function task_added(event : task_event) : boolean
+		local task = event.item;
+		if task.name == nil then
+			task.name = task.when;
+		end
+		if task.id == nil then
+			task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower();
+		end
+		if task.last == nil then
+			task.last = last_run_times:get(nil, task.id);
+		end
+		task.save = save_task;
+		module:log("debug", "%s task %s added, last run %s", task.when, task.id,
+			task.last and datetime.datetime(task.last) or "never");
+		if task.last == nil then
+			-- initialize new tasks so e.g. daily tasks run at ~midnight UTC for now
+			local now = os.time();
+			task.last = now - now % periods[task.when];
+		end
+		return true;
+	end
+
+	local function task_removed(event : task_event) : boolean
+		local task = event.item;
+		host_module:log("debug", "Task %s removed", task.id);
+		return true;
+	end
+
+	host_module:handle_items("task", task_added, task_removed, true);
+
+	function host_module.unload()
+		active_hosts[host_module.host]=nil;
+	end
+end
+
+local function should_run(when : frequency, last : integer) : boolean
+	return not last or last + periods[when]*0.995 <= os.time();
+end
+
+local function run_task(task : task_spec)
+	local started_at = os.time();
+	task:run(started_at);
+	task.last = started_at;
+	task:save(started_at);
+end
+
+local task_runner = async.runner(run_task);
+module:add_timer(1, function() : integer
+	module:log("info", "Running periodic tasks");
+	local delay = 3600;
+	for host in pairs(active_hosts) do
+		module:log("debug", "Running periodic tasks for host %s", host);
+		for _, task in ipairs(module:context(host):get_host_items("task") as { task_spec } ) do
+			module:log("debug", "Considering %s task %s (%s)", task.when, task.id, task.run);
+			if should_run(task.when, task.last) then task_runner:run(task); end
+		end
+	end
+	module:log("debug", "Wait %ds", delay);
+	return delay;
+end);
+
+-- TODO measure load, pick a good time to do stuff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/compat.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,4 @@
+local record lib
+	xpcall : function (function, function, ...:any):boolean, any
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/crand.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,6 @@
+local record lib
+	bytes : function (n : integer) : string
+	enum sourceid "OpenSSL" "arc4random()" "Linux" end
+	_source : sourceid
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/dataforms.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,52 @@
+local stanza_t = require "util.stanza".stanza_t
+
+local enum form_type
+	"form"
+	"submit"
+	"cancel"
+	"result"
+end
+
+local enum field_type
+	"boolean"
+	"fixed"
+	"hidden"
+	"jid-multi"
+	"jid-single"
+	"list-multi"
+	"list-single"
+	"text-multi"
+	"text-private"
+	"text-single"
+end
+
+local record form_field
+
+	type : field_type
+	var : string -- protocol name
+	name :  string -- internal name
+
+	label : string
+	desc : string
+
+	datatype : string
+	range_min : number
+	range_max : number
+
+	value : any -- depends on field_type
+	options : table
+end
+
+local record dataform
+	title : string
+	instructions : string
+	{ form_field } -- XXX https://github.com/teal-language/tl/pull/415
+
+	form : function ( dataform, table, form_type ) : stanza_t
+end
+
+local record lib
+	new : function ( dataform ) : dataform
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/datamapper.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,381 @@
+-- Copyright (C) 2021 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- Based on
+-- https://json-schema.org/draft/2020-12/json-schema-core.html
+-- https://json-schema.org/draft/2020-12/json-schema-validation.html
+-- http://spec.openapis.org/oas/v3.0.1#xmlObject
+-- https://github.com/OAI/OpenAPI-Specification/issues/630 (text:true)
+--
+-- XML Object Extensions:
+-- text to refer to the text content at the same time as attributes
+-- x_name_is_value for enum fields where the <tag-name/> is the value
+-- x_single_attribute for <tag attr="this"/>
+--
+-- TODO pointers
+-- TODO cleanup / refactor
+-- TODO s/number/integer/ once we have appropriate math.type() compat
+--
+
+local st = require "util.stanza";
+local json = require"util.json"
+local pointer = require"util.jsonpointer";
+
+local json_type_name = json.json_type_name;
+local json_schema_object = require "util.jsonschema"
+local type schema_t = boolean | json_schema_object
+
+local function toboolean ( s : string ) : boolean
+	if s == "true" or s == "1" then
+		return true
+	elseif s == "false" or s == "0" then
+		return false
+	elseif s then
+		return true
+	end
+end
+
+local function totype(t : json_type_name, s : string) : any
+	if not s then return nil end
+	if t == "string" then
+		return s;
+	elseif t == "boolean" then
+		return toboolean(s)
+	elseif t == "number" or t == "integer" then
+		return tonumber(s)
+	end
+end
+
+local enum value_goes
+	"in_tag_name"
+	"in_text"
+	"in_text_tag"
+	"in_attribute"
+	"in_single_attribute"
+	"in_children"
+	"in_wrapper"
+end
+
+local function resolve_schema(schema  : schema_t, root : json_schema_object) : schema_t
+	if schema is json_schema_object then
+		if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
+			return pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t;
+		end
+	end
+	return schema;
+end
+
+local function guess_schema_type(schema : json_schema_object) : json_type_name
+	local schema_types = schema.type
+	if schema_types is json_type_name then
+		return schema_types
+	elseif schema_types ~= nil then
+		error "schema has unsupported 'type' property"
+	elseif schema.properties then
+		return "object"
+	elseif schema.items then
+		return "array"
+	end
+	return "string" -- default assumption
+end
+
+local function unpack_propschema( propschema : schema_t, propname : string, current_ns : string )
+		: json_type_name, value_goes, string, string, string, string, { any }
+	local proptype : json_type_name = "string"
+	local value_where : value_goes = propname and "in_text_tag" or "in_text"
+	local name = propname
+	local namespace : string
+	local prefix : string
+	local single_attribute : string
+	local enums : { any }
+
+	if propschema is json_schema_object then
+		proptype = guess_schema_type(propschema);
+	elseif propschema is string then -- Teal says this can never be a string, but it could before so best be sure
+		error("schema as string is not supported: "..propschema.." {"..current_ns.."}"..propname)
+	end
+
+	if proptype == "object" or proptype == "array" then
+		value_where = "in_children"
+	end
+
+	if propschema is json_schema_object then
+		local xml = propschema.xml
+		if xml then
+			if xml.name then
+				name = xml.name
+			end
+			if xml.namespace and xml.namespace ~= current_ns then
+				namespace = xml.namespace
+			end
+			if xml.prefix then
+				prefix = xml.prefix
+			end
+			if proptype == "array" and xml.wrapped then
+				value_where = "in_wrapper"
+			elseif xml.attribute then
+				value_where = "in_attribute"
+			elseif xml.text then
+				value_where = "in_text"
+			elseif xml.x_name_is_value then
+				value_where = "in_tag_name"
+			elseif xml.x_single_attribute then
+				single_attribute = xml.x_single_attribute
+				value_where = "in_single_attribute"
+			end
+		end
+		if propschema["const"] then
+			enums = { propschema["const"] }
+		elseif propschema["enum"] then
+			enums = propschema["enum"]
+		end
+	end
+
+	if current_ns == "urn:xmpp:reactions:0" and name == "reactions" then
+		assert(proptype=="array")
+	end
+
+	return proptype, value_where, name, namespace, prefix, single_attribute, enums
+end
+
+local parse_object : function (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { string : any }
+local parse_array : function (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { any }
+
+local function extract_value (s : st.stanza_t, value_where : value_goes, proptype : json.json_type_name, name : string, namespace : string, prefix : string, single_attribute : string, enums : { any }) : string
+	if value_where == "in_tag_name" then
+		local c : st.stanza_t
+		if proptype == "boolean" then
+			c = s:get_child(name, namespace);
+		elseif enums and proptype == "string" then
+			-- XXX O(n²) ?
+			-- Probably better to flip the table and loop over :childtags(nil, ns), should be 2xO(n)
+			-- BUT works first, optimize later
+			for i = 1, #enums do
+				c = s:get_child(enums[i] as string, namespace);
+				if c then break end
+			end
+		else
+			c = s:get_child(nil, namespace);
+		end
+		if c then
+			return c.name;
+		end
+	elseif value_where == "in_attribute" then
+		local attr = name
+		if prefix then
+			attr = prefix .. ':' .. name
+		elseif namespace and namespace ~= s.attr.xmlns then
+			attr = namespace .. "\1" .. name
+		end
+		return s.attr[attr]
+
+	elseif value_where == "in_text" then
+		return s:get_text()
+
+	elseif value_where == "in_single_attribute" then
+		local c = s:get_child(name, namespace)
+		return c and c.attr[single_attribute]
+	elseif value_where == "in_text_tag" then
+		return s:get_child_text(name, namespace)
+	end
+end
+
+function parse_object (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { string : any }
+	local out : { string : any } = {}
+	schema = resolve_schema(schema, root)
+	if schema is json_schema_object and schema.properties then
+		for prop, propschema in pairs(schema.properties) do
+			propschema = resolve_schema(propschema, root)
+
+			local proptype, value_where, name, namespace, prefix, single_attribute, enums = unpack_propschema(propschema, prop, s.attr.xmlns)
+
+			if value_where == "in_children" and propschema is json_schema_object then
+				if proptype == "object" then
+					local c = s:get_child(name, namespace)
+					if c then
+						out[prop] = parse_object(propschema, c, root);
+					end
+				elseif proptype == "array" then
+					local a = parse_array(propschema, s, root);
+					if a and a[1] ~= nil then
+						out[prop] = a;
+					end
+				else
+					error "unreachable"
+				end
+			elseif value_where == "in_wrapper" and propschema is json_schema_object and proptype == "array" then
+				local wrapper = s:get_child(name, namespace);
+				if wrapper then
+					out[prop] = parse_array(propschema, wrapper, root);
+				end
+			else
+				local value : string = extract_value (s, value_where, proptype, name, namespace, prefix, single_attribute, enums)
+
+				out[prop] = totype(proptype, value)
+			end
+		end
+	end
+
+	return out
+end
+
+function parse_array (schema : json_schema_object, s : st.stanza_t, root : json_schema_object) : { any }
+	local itemschema : schema_t = resolve_schema(schema.items, root);
+	local proptype, value_where, child_name, namespace, prefix, single_attribute, enums = unpack_propschema(itemschema, nil, s.attr.xmlns)
+	local attr_name : string
+	if value_where == "in_single_attribute" then -- FIXME this shouldn't be needed
+		value_where = "in_attribute";
+		attr_name = single_attribute;
+	end
+	local out : { any } = {}
+
+	if proptype == "object" then
+		if itemschema is json_schema_object then
+			for c in s:childtags(child_name, namespace) do
+				table.insert(out, parse_object(itemschema, c, root));
+			end
+		else
+			error "array items must be schema object"
+		end
+	elseif proptype == "array" then
+		if itemschema is json_schema_object then
+			for c in s:childtags(child_name, namespace) do
+				table.insert(out, parse_array(itemschema, c, root));
+			end
+		end
+	else
+		for c in s:childtags(child_name, namespace) do
+			local value : string = extract_value (c, value_where, proptype, attr_name or child_name, namespace, prefix, single_attribute, enums)
+
+			table.insert(out, totype(proptype, value));
+		end
+	end
+	return out;
+end
+
+local function parse (schema : json_schema_object, s : st.stanza_t) : table
+	local s_type = guess_schema_type(schema)
+	if s_type == "object" then
+		return parse_object(schema, s, schema)
+	elseif s_type == "array" then
+		return parse_array(schema, s, schema)
+	else
+		error "top-level scalars unsupported"
+	end
+end
+
+local function toxmlstring(proptype : json_type_name, v : any) : string
+	if proptype == "string" and v is string then
+		return  v
+	elseif proptype == "number" and v is number then
+		return  string.format("%g", v)
+	elseif proptype == "integer" and v is number then -- TODO is integer
+		return  string.format("%d", v)
+	elseif proptype == "boolean" then
+		return  v and "1" or "0"
+	end
+end
+
+local unparse : function (json_schema_object, table, string, string, st.stanza_t, json_schema_object) : st.stanza_t
+
+local function unparse_property(out : st.stanza_t, v : any, proptype : json_type_name, propschema : schema_t, value_where : value_goes, name : string, namespace : string, current_ns : string, prefix : string, single_attribute : string, root : json_schema_object)
+
+	if value_where == "in_attribute" then
+		local attr = name
+		if prefix then
+			attr = prefix .. ':' .. name
+		elseif namespace and namespace ~= current_ns then
+			attr = namespace .. "\1" .. name
+		end
+
+		out.attr[attr] = toxmlstring(proptype, v)
+	elseif value_where == "in_text" then
+		out:text(toxmlstring(proptype, v))
+	elseif value_where == "in_single_attribute" then
+		assert(single_attribute)
+		local propattr : { string : string } = {}
+
+		if namespace and namespace ~= current_ns then
+			propattr.xmlns = namespace
+		end
+
+		propattr[single_attribute] = toxmlstring(proptype, v)
+		out:tag(name, propattr):up();
+
+	else
+		local propattr : { string : string }
+		if namespace ~= current_ns then
+			propattr = { xmlns = namespace }
+		end
+		if value_where == "in_tag_name" then
+			if proptype == "string" and v is string then
+				out:tag(v, propattr):up();
+			elseif proptype == "boolean" and v == true then
+				out:tag(name, propattr):up();
+			end
+		elseif proptype == "object" and propschema is json_schema_object and v is table then
+			local c = unparse(propschema, v, name, namespace, nil, root);
+			if c then
+				out:add_direct_child(c);
+			end
+		elseif proptype == "array" and propschema is json_schema_object and v is table then
+			if value_where == "in_wrapper" then
+				local c = unparse(propschema, v, name, namespace, nil, root);
+				if c then
+					out:add_direct_child(c);
+				end
+			else
+				unparse(propschema, v, name, namespace, out, root);
+			end
+		else
+			out:text_tag(name, toxmlstring(proptype, v), propattr)
+		end
+	end
+end
+
+function unparse ( schema : json_schema_object, t : table, current_name : string, current_ns : string, ctx : st.stanza_t, root : json_schema_object ) : st.stanza_t
+
+	if root == nil then root = schema end
+
+	if schema.xml then
+		if schema.xml.name then
+			current_name = schema.xml.name
+		end
+		if schema.xml.namespace then
+			current_ns = schema.xml.namespace
+		end
+		-- TODO prefix?
+	end
+
+	local out = ctx or st.stanza(current_name, { xmlns = current_ns })
+
+	local s_type = guess_schema_type(schema)
+	if s_type == "object" then
+
+		for prop, propschema in pairs(schema.properties) do
+			propschema = resolve_schema(propschema, root)
+			local v = t[prop]
+
+			if v ~= nil then
+				local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(propschema, prop, current_ns)
+				unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
+			end
+		end
+		return out;
+
+	elseif s_type == "array" then
+		local itemschema = resolve_schema(schema.items, root)
+		local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(itemschema, current_name, current_ns)
+		for _, item in ipairs(t as { string }) do
+			unparse_property(out, item, proptype, itemschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
+		end
+		return out;
+	end
+end
+
+return {
+	parse = parse,
+	unparse = unparse,
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/datetime.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,11 @@
+-- TODO s/number/integer/ once Teal gets support for that
+
+local record lib
+	date     : function (t : integer) : string
+	datetime : function (t : integer) : string
+	time     : function (t : integer) : string
+	legacy   : function (t : integer) : string
+	parse    : function (t : string) : integer
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/encodings.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,27 @@
+-- TODO many actually return Maybe(String)
+local record lib
+	record base64
+		encode : function (s : string) : string
+		decode : function (s : string) : string
+	end
+	record stringprep
+		nameprep : function (s : string, strict : boolean) : string
+		nodeprep : function (s : string, strict : boolean) : string
+		resourceprep : function (s : string, strict : boolean) : string
+		saslprep : function (s : string, strict : boolean) : string
+	end
+	record idna
+		to_ascii : function (s : string) : string
+		to_unicode : function (s : string) : string
+	end
+	record utf8
+		valid : function (s : string) : boolean
+		length : function (s : string) : integer
+	end
+	record confusable
+		skeleton : function (s : string) : string
+	end
+	version : string
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/error.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,78 @@
+local enum error_type
+	"auth"
+	"cancel"
+	"continue"
+	"modify"
+	"wait"
+end
+
+local enum error_condition
+	"bad-request"
+	"conflict"
+	"feature-not-implemented"
+	"forbidden"
+	"gone"
+	"internal-server-error"
+	"item-not-found"
+	"jid-malformed"
+	"not-acceptable"
+	"not-allowed"
+	"not-authorized"
+	"policy-violation"
+	"recipient-unavailable"
+	"redirect"
+	"registration-required"
+	"remote-server-not-found"
+	"remote-server-timeout"
+	"resource-constraint"
+	"service-unavailable"
+	"subscription-required"
+	"undefined-condition"
+	"unexpected-request"
+end
+
+local record protoerror
+	type : error_type
+	condition : error_condition
+	text : string
+	code : integer
+end
+
+local record error
+	type : error_type
+	condition : error_condition
+	text : string
+	code : integer
+	context : { any : any }
+	source : string
+end
+
+local type compact_registry_item = { string, string, string, string }
+local type compact_registry = { compact_registry_item }
+local type registry = { string : protoerror }
+local type context = { string : any }
+
+local record error_registry_wrapper
+	source : string
+	registry : registry
+	new : function (string, context) : error
+	coerce : function (any, string) : any, error
+	wrap : function (error) : error
+	wrap : function (string, context) : error
+	is_error : function (any) : boolean
+end
+
+local record lib
+	record configure_opt
+		auto_inject_traceback : boolean
+	end
+	new : function (protoerror, context, { string : protoerror }, string) : error
+	init : function (string, string, registry | compact_registry) : error_registry_wrapper
+	init : function (string, registry | compact_registry) : error_registry_wrapper
+	is_error : function (any) : boolean
+	coerce : function (any, string) : any, error
+	from_stanza : function (table, context, string) : error
+	configure : function
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/format.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,4 @@
+local record lib
+	format : function (string, ... : any) : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/hashes.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,23 @@
+local type hash = function (msg : string, hex : boolean) : string
+local type hmac = function (key : string, msg : string, hex : boolean) : string
+local type kdf = function (pass : string, salt : string, i : integer) : string
+
+local record lib
+	sha1 : hash
+	sha256 : hash
+	sha224 : hash
+	sha384 : hash
+	sha512 : hash
+	md5 : hash
+	hmac_sha1 : hmac
+	hmac_sha256 : hmac
+	hmac_sha512 : hmac
+	hmac_md5 : hmac
+	scram_Hi_sha1 : kdf
+	pbkdf2_hmac_sha1 : kdf
+	pbkdf2_hmac_sha256 : kdf
+	equals : function (string, string) : boolean
+	version : string
+	_LIBCRYPTO_VERSION : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/hex.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,6 @@
+local type s2s = function (s : string) : string
+local record lib
+	to : s2s
+	from : s2s
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/http.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,9 @@
+local record lib
+	urlencode : function (s : string) : string 
+	urldecode : function (s : string) : string 
+	formencode : function (f : { string : string }) : string 
+	formdecode : function (s : string) : { string : string } 
+	contains_token : function (field : string, token : string) : boolean 
+	normalize_path : function (path : string) : string 
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/human/units.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,5 @@
+local lib = record
+	adjust : function (number, string) : number, string
+	format : function (number, string, string) : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/id.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,9 @@
+local record lib
+	short : function () : string
+	medium : function () : string
+	long : function () : string
+	custom : function (integer) : function () : string
+
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/interpolation.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,6 @@
+local type renderer = function (string, { string : any }) : string
+local type filter = function (string, any) : string
+local record lib
+	new : function (string, string, funcs : { string : filter }) : renderer
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/jid.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,15 @@
+local record lib
+	split : function (string) : string, string, string
+	bare : function (string) : string
+	prepped_split : function (string, boolean) : string, string, string
+	join : function (string, string, string) : string
+	prep : function (string, boolean) : string
+	compare : function (string, string) : boolean
+	node : function (string) : string
+	host : function (string) : string
+	resource : function (string) : string
+	escape : function (string) : string
+	unescape : function (string) : string
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/json.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,18 @@
+local record lib
+	encode : function (any) : string
+	decode : function (string) : any, string
+
+	enum json_type_name
+		"null"
+		"boolean"
+		"object"
+		"array"
+		"number"
+		"string"
+		"integer"
+	end
+
+	type null_type = (nil)
+	null : null_type
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/jsonpointer.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,46 @@
+
+local enum ptr_error
+	"invalid-table"
+	"invalid-path"
+end
+
+local function unescape_token(escaped_token : string) : string
+	local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~")
+	return unescaped
+end
+
+local function resolve_json_pointer(ref : table, path : string) : any, ptr_error
+	local ptr_len = #path+1
+	for part, pos in path:gmatch("/([^/]*)()") do
+		local token = unescape_token(part)
+		if not ref is table then
+			return nil
+		end
+		local idx = next(ref)
+		local new_ref : any
+
+		if idx is string then
+			new_ref = ref[token]
+		elseif idx is integer then
+			local i = tonumber(token)
+			if token == "-" then i = #ref + 1 end
+			new_ref = ref[i+1]
+		else
+			return nil, "invalid-table"
+		end
+
+		if pos as integer == ptr_len then
+			return new_ref
+		elseif new_ref is table then
+			ref = new_ref
+		elseif not ref is table then
+			return nil, "invalid-path"
+		end
+
+	end
+	return ref
+end
+
+return {
+	resolve = resolve_json_pointer,
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/jsonschema.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,374 @@
+-- Copyright (C) 2021 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- Based on
+-- https://json-schema.org/draft/2020-12/json-schema-core.html
+-- https://json-schema.org/draft/2020-12/json-schema-validation.html
+--
+
+local json = require"util.json"
+local null = json.null;
+
+local pointer = require "util.jsonpointer"
+
+local type json_type_name = json.json_type_name
+
+-- json_type_name here is non-standard
+local type schema_t = boolean | json_schema_object
+
+local record json_schema_object
+	type json_type_name = json.json_type_name
+	type schema_object = json_schema_object
+
+	type : json_type_name | { json_type_name }
+	enum : { any }
+	const : any
+
+	allOf : { schema_t }
+	anyOf : { schema_t }
+	oneOf : { schema_t }
+
+	["not"] : schema_t
+	["if"] : schema_t
+	["then"] : schema_t
+	["else"] : schema_t
+
+	["$ref"] : string
+
+	-- numbers
+	multipleOf : number
+	maximum : number
+	exclusiveMaximum : number
+	minimum : number
+	exclusiveMinimum : number
+
+	-- strings
+	maxLength : integer
+	minLength : integer
+	pattern : string -- NYI
+	format : string
+
+	-- arrays
+	prefixItems : { schema_t }
+	items : schema_t
+	contains : schema_t
+	maxItems : integer
+	minItems : integer
+	uniqueItems : boolean
+	maxContains : integer -- NYI
+	minContains : integer -- NYI
+
+	-- objects
+	properties : { string : schema_t }
+	maxProperties : integer -- NYI
+	minProperties : integer -- NYI
+	required : { string }
+	dependentRequired : { string : { string } }
+	additionalProperties: schema_t
+	patternProperties: schema_t -- NYI
+	propertyNames : schema_t
+
+	-- xml
+	record xml_t
+		name : string
+		namespace : string
+		prefix : string
+		attribute : boolean
+		wrapped : boolean
+
+		-- nonstantard, maybe in the future
+		text : boolean
+		x_name_is_value : boolean
+		x_single_attribute : string
+	end
+
+	xml : xml_t
+
+	-- descriptive
+	title : string
+	description : string
+	deprecated : boolean
+	readOnly : boolean
+	writeOnly : boolean
+
+	-- methods
+	validate : function ( schema_t, any, json_schema_object ) : boolean
+end
+
+-- TODO validator function per schema property
+
+local function simple_validate(schema : json_type_name | { json_type_name }, data : any) : boolean
+	if schema == nil then
+		return true
+	elseif schema == "object" and data is table then
+		return type(data) == "table" and (next(data)==nil or type((next(data, nil))) == "string")
+	elseif schema == "array" and data is table then
+		return type(data) == "table" and (next(data)==nil or type((next(data, nil))) == "number")
+	elseif schema == "integer" then
+		return math.type(data) == schema
+	elseif schema == "null" then
+		return data == null
+	elseif schema is { json_type_name } then
+		for _, one in ipairs(schema as { json_type_name }) do
+			if simple_validate(one, data) then
+				return true
+			end
+		end
+		return false
+	else
+		return type(data) == schema
+	end
+end
+
+local complex_validate : function ( json_schema_object, any, json_schema_object ) : boolean
+
+local function validate (schema : schema_t, data : any, root : json_schema_object) : boolean
+	if schema is boolean then
+		return schema
+	else
+		return complex_validate(schema, data, root)
+	end
+end
+
+function complex_validate (schema : json_schema_object, data : any, root : json_schema_object) : boolean
+
+	if root == nil then
+		root = schema
+	end
+
+	if schema["$ref"] and schema["$ref"]:sub(1,1) == "#" then
+		local referenced = pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t
+		if referenced ~= nil and referenced ~= root and referenced ~= schema then
+			if not validate(referenced, data, root) then
+				return false;
+			end
+		end
+	end
+
+	if not simple_validate(schema.type, data) then
+		return false;
+	end
+
+	if schema.type == "object" then
+		if data is table then
+			-- just check that there the keys are all strings
+			for k in pairs(data) do
+				if not k is string then
+					return false
+				end
+			end
+		end
+	end
+
+	if schema.type == "array" then
+		if data is table then
+			-- just check that there the keys are all numbers
+			for i in pairs(data) do
+				if not i is integer then
+					return false
+				end
+			end
+		end
+	end
+
+	if schema["enum"] ~= nil then
+		local match = false
+		for _, v in ipairs(schema["enum"]) do
+			if v == data then
+				-- FIXME supposed to do deep-compare
+				match = true
+				break
+			end
+		end
+		if not match then
+			return false
+		end
+	end
+
+	-- XXX this is measured in byte, while JSON measures in ... bork
+	-- TODO use utf8.len?
+	if data is string then
+		if schema.maxLength and #data > schema.maxLength then
+			return false
+		end
+		if schema.minLength and #data < schema.minLength then
+			return false
+		end
+	end
+
+	if data is number then
+		if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then
+			return false
+		end
+
+		if schema.maximum and not ( data <= schema.maximum ) then
+			return false
+		end
+
+		if schema.exclusiveMaximum and not ( data < schema.exclusiveMaximum ) then
+			return false
+		end
+
+		if schema.minimum and not ( data >= schema.minimum ) then
+			return false
+		end
+
+		if schema.exclusiveMinimum and not ( data > schema.exclusiveMinimum ) then
+			return false
+		end
+	end
+
+	if schema.allOf then
+		for _, sub in ipairs(schema.allOf) do
+			if not validate(sub, data, root) then
+				return false
+			end
+		end
+	end
+
+	if schema.oneOf then
+		local valid = 0
+		for _, sub in ipairs(schema.oneOf) do
+			if validate(sub, data, root) then
+				valid = valid + 1
+			end
+		end
+		if valid ~= 1 then
+			return false
+		end
+	end
+
+	if schema.anyOf then
+		local match = false
+		for _, sub in ipairs(schema.anyOf) do
+			if validate(sub, data, root) then
+				match = true
+				break
+			end
+		end
+		if not match then
+			return false
+		end
+	end
+
+	if schema["not"] then
+		if validate(schema["not"], data, root) then
+			return false
+		end
+	end
+
+	if schema["if"] ~= nil then
+		if validate(schema["if"], data, root) then
+			if schema["then"] then
+				return validate(schema["then"], data, root)
+			end
+		else
+			if schema["else"] then
+				return validate(schema["else"], data, root)
+			end
+		end
+	end
+
+	if schema.const ~= nil and schema.const ~= data then
+		return false
+	end
+
+	if data is table then
+
+		if schema.maxItems and #data > schema.maxItems then
+			return false
+		end
+
+		if schema.minItems and #data < schema.minItems then
+			return false
+		end
+
+		if schema.required then
+			for _, k in ipairs(schema.required) do
+				if data[k] == nil then
+					return false
+				end
+			end
+		end
+
+		if schema.propertyNames ~= nil then
+			for k in pairs(data) do
+				if not validate(schema.propertyNames, k, root) then
+					return false
+				end
+			end
+		end
+
+		if schema.properties then
+			for k, sub in pairs(schema.properties) do
+				if data[k] ~= nil and not validate(sub, data[k], root) then
+					return false
+				end
+			end
+		end
+
+		if schema.additionalProperties ~= nil then
+			for k, v in pairs(data) do
+				if schema.properties == nil or schema.properties[k as string] == nil then
+					if not validate(schema.additionalProperties, v, root) then
+						return false
+					end
+				end
+			end
+		end
+
+		if schema.uniqueItems then
+			-- only works for scalars, would need to deep-compare for objects/arrays/tables
+			local values : { any : boolean } = {}
+			for _, v in pairs(data) do
+				if values[v] then
+					return false
+				end
+				values[v] = true
+			end
+		end
+
+		local p = 0
+		if schema.prefixItems ~= nil then
+			for i, s in ipairs(schema.prefixItems) do
+				if data[i] == nil then
+					break
+				elseif validate(s, data[i], root) then
+					p = i
+				else
+					return false
+				end
+			end
+		end
+
+		if schema.items ~= nil then
+			for i = p+1, #data do
+				if not validate(schema.items, data[i], root) then
+					return false
+				end
+			end
+		end
+
+		if schema.contains ~= nil then
+			local found = false
+			for i = 1, #data do
+				if validate(schema.contains, data[i], root) then
+					found = true
+					break
+				end
+			end
+			if not found then
+				return false
+			end
+		end
+	end
+
+	return true;
+end
+
+
+json_schema_object.validate = validate;
+
+return json_schema_object;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/net.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,13 @@
+
+local enum type_strings
+	"both"
+	"ipv4"
+	"ipv6"
+end
+
+local record lib
+	local_addresses : function (type_strings, boolean) : { string }
+	pton : function (string):string
+	ntop : function (string):string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/poll.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,31 @@
+local record state
+	enum waiterr
+		"timeout"
+		"signal"
+	end
+	add : function (state, integer, boolean, boolean) : boolean
+	add : function (state, integer, boolean, boolean) : nil, string, integer
+	set : function (state, integer, boolean, boolean) : boolean
+	set : function (state, integer, boolean, boolean) : nil, string, integer
+	del : function (state, integer) : boolean
+	del : function (state, integer) : nil, string, integer
+	wait : function (state, integer) : integer, boolean, boolean
+	wait : function (state, integer) : nil, string, integer
+	wait : function (state, integer) : nil, waiterr
+	getfd : function (state) : integer
+end
+
+local record lib
+	new : function () : state
+	EEXIST : integer
+	EMFILE : integer
+	ENOENT : integer
+	enum api_backend
+		"epoll"
+		"poll"
+		"select"
+	end
+	api : api_backend
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/pposix.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,108 @@
+local record pposix
+	enum syslog_facility
+		"auth"
+		"authpriv"
+		"cron"
+		"daemon"
+		"ftp"
+		"kern"
+		"local0"
+		"local1"
+		"local2"
+		"local3"
+		"local4"
+		"local5"
+		"local6"
+		"local7"
+		"lpr"
+		"mail"
+		"syslog"
+		"user"
+		"uucp"
+	end
+
+	enum syslog_level
+		"debug"
+		"info"
+		"notice"
+		"warn"
+		"error"
+	end
+
+	enum ulimit_resource
+		"CORE"
+		"CPU"
+		"DATA"
+		"FSIZE"
+		"NOFILE"
+		"STACK"
+		"MEMLOCK"
+		"NPROC"
+		"RSS"
+		"NICE"
+	end
+
+	enum ulimit_unlimited
+		"unlimited"
+	end
+
+	type ulimit_limit = integer | ulimit_unlimited
+
+	record utsname
+		sysname         :  string
+		nodename        :  string
+		release         :  string
+		version         :  string
+		machine         :  string
+		domainname      :  string
+	end
+
+	record memoryinfo
+		allocated       :  integer
+		allocated_mmap  :  integer
+		used            :  integer
+		unused          :  integer
+		returnable      :  integer
+	end
+
+	abort : function ()
+
+	daemonize : function () : boolean, string
+
+	syslog_open : function (ident : string, facility : syslog_facility)
+	syslog_close : function ()
+	syslog_log : function (level : syslog_level, src : string, msg : string)
+	syslog_setminlevel : function (level : syslog_level)
+
+	getpid : function () : integer
+	getuid : function () : integer
+	getgid : function () : integer
+
+	setuid : function (uid : integer | string) : boolean, string -- string|integer
+	setgid : function (uid : integer | string) : boolean, string
+	initgroups : function (user : string, gid : integer) : boolean, string
+
+	umask : function (umask : string) : string
+
+	mkdir : function (dir : string) : boolean, string
+
+	setrlimit : function (resource : ulimit_resource, soft : ulimit_limit, hard : ulimit_limit) : boolean, string
+	getrlimit : function (resource : ulimit_resource) : boolean, ulimit_limit, ulimit_limit
+	getrlimit : function (resource : ulimit_resource) : boolean, string
+
+	uname : function () : utsname
+
+	setenv : function (key : string, value : string) : boolean
+
+	meminfo : function () : memoryinfo
+
+	atomic_append : function (f : FILE, s : string) : boolean, string, integer
+
+	isatty : function(FILE) : boolean
+
+	ENOENT : integer
+	_NAME : string
+	_VESRION : string
+end
+
+return pposix
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/random.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,4 @@
+local record lib
+	bytes : function (n:integer):string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/ringbuffer.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,20 @@
+local record lib
+	record ringbuffer
+		find : function (ringbuffer, string) : integer
+		discard : function (ringbuffer, integer) : boolean
+		read : function (ringbuffer, integer, boolean) : string
+		readuntil : function (ringbuffer, string) : string
+		write : function (ringbuffer, string) : integer
+		size : function (ringbuffer) : integer
+		length : function (ringbuffer) : integer
+		sub : function (ringbuffer, integer, integer) : string
+		byte : function (ringbuffer, integer, integer) : integer...
+		free : function (ringbuffer) : integer
+	end
+
+	new : function (integer) : ringbuffer
+end
+
+return lib
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/signal.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,41 @@
+local record lib
+	enum signal
+		"SIGABRT"
+		"SIGALRM"
+		"SIGBUS"
+		"SIGCHLD"
+		"SIGCLD"
+		"SIGCONT"
+		"SIGFPE"
+		"SIGHUP"
+		"SIGILL"
+		"SIGINT"
+		"SIGIO"
+		"SIGIOT"
+		"SIGKILL"
+		"SIGPIPE"
+		"SIGPOLL"
+		"SIGPROF"
+		"SIGQUIT"
+		"SIGSEGV"
+		"SIGSTKFLT"
+		"SIGSTOP"
+		"SIGSYS"
+		"SIGTERM"
+		"SIGTRAP"
+		"SIGTTIN"
+		"SIGTTOU"
+		"SIGURG"
+		"SIGUSR1"
+		"SIGUSR2"
+		"SIGVTALRM"
+		"SIGWINCH"
+		"SIGXCPU"
+		"SIGXFSZ"
+	end
+	signal : function (integer | signal, function, boolean) : boolean
+	raise : function (integer | signal)
+	kill : function (integer, integer | signal)
+	-- enum : integer
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/smqueue.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,99 @@
+local queue = require "util.queue";
+
+local record lib
+	-- T would typically be util.stanza
+	record smqueue<T>
+		_queue : queue.queue<T>
+		_head : integer
+		_tail : integer
+
+		enum ack_errors
+			"tail"
+			"head"
+			"pop"
+		end
+		push : function (smqueue, T)
+		ack : function (smqueue, integer) : { T }, ack_errors
+		resumable : function (smqueue<T>) : boolean
+		resume : function (smqueue<T>)  : queue.queue.iterator, any, integer
+		type consume_iter = function (smqueue<T>) : T
+		consume : function (smqueue<T>) : consume_iter
+
+		table : function (smqueue<T>) : { T }
+	end
+	new : function <T>(integer) : smqueue<T>
+end
+
+local type smqueue = lib.smqueue;
+
+function smqueue:push(v)
+	self._head = self._head + 1;
+	-- Wraps instead of errors
+	assert(self._queue:push(v));
+end
+
+function smqueue:ack(h : integer) : { any }, smqueue.ack_errors
+	if h < self._tail then
+		return nil, "tail";
+	elseif h > self._head then
+		return nil, "head";
+	end
+	-- TODO optimize? cache table fields
+	local acked = {};
+	self._tail = h;
+	local expect = self._head - self._tail;
+	while expect < self._queue:count() do
+		local v = self._queue:pop();
+		if not v then return nil, "pop"; end
+		table.insert(acked, v);
+	end
+	return acked;
+end
+
+function smqueue:count_unacked() : integer
+	return self._head - self._tail;
+end
+
+function smqueue:count_acked() : integer
+	return self._tail;
+end
+
+function smqueue:resumable() : boolean
+	return self._queue:count() >= (self._head - self._tail);
+end
+
+function smqueue:resume() : queue.queue.iterator, any, integer
+	return self._queue:items();
+end
+
+function smqueue:consume() : queue.queue.consume_iter
+	return self._queue:consume()
+end
+
+-- Compatibility layer, plain ol' table
+function smqueue:table() : { any }
+	local t : { any } = {};
+	for i, v in self:resume() do
+		t[i] = v;
+	end
+	return t;
+end
+
+local function freeze(q : smqueue<any>) : { string:integer }
+	return { head = q._head, tail = q._tail }
+end
+
+local queue_mt = {
+	--
+	__name = "smqueue";
+	__index = smqueue;
+	__len = smqueue.count_unacked;
+	__freeze = freeze;
+}
+
+function lib.new<T>(size : integer) : queue.queue<T>
+	assert(size>0);
+	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt);
+end
+
+return lib;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/stanza.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,62 @@
+local record lib
+
+	type children_iter = function ( stanza_t ) : stanza_t
+	type childtags_iter = function () : stanza_t
+	type maptags_cb = function ( stanza_t ) : stanza_t
+
+	record stanza_t
+		name : string
+		attr : { string : string }
+		{ stanza_t | string }
+		tags : { stanza_t }
+
+		query : function ( stanza_t, string ) : stanza_t
+		body : function ( stanza_t, string, { string : string } ) : stanza_t
+		text_tag : function ( stanza_t, string, string, { string : string } ) : stanza_t
+		tag : function ( stanza_t, string, { string : string } ) : stanza_t
+		text : function ( stanza_t, string ) : stanza_t
+		up : function ( stanza_t ) : stanza_t
+		reset : function ( stanza_t ) : stanza_t
+		add_direct_child : function ( stanza_t, stanza_t )
+		add_child : function ( stanza_t, stanza_t )
+		remove_children : function ( stanza_t, string, string ) : stanza_t
+
+		get_child : function ( stanza_t, string, string ) : stanza_t
+		get_text : function ( stanza_t ) : string
+		get_child_text : function ( stanza_t, string, string ) : string
+		child_with_name : function ( stanza_t, string, string ) : stanza_t
+		child_with_ns : function ( stanza_t, string, string ) : stanza_t
+		children : function ( stanza_t ) : children_iter, stanza_t, integer
+		childtags : function ( stanza_t, string, string ) : childtags_iter
+		maptags : function ( stanza_t, maptags_cb ) : stanza_t
+		find : function ( stanza_t, string ) : stanza_t | string
+
+		top_tag : function ( stanza_t ) : string
+		pretty_print : function ( stanza_t ) : string
+		pretty_top_tag : function ( stanza_t ) : string
+
+		get_error : function ( stanza_t ) : string, string, string, stanza_t
+		indent : function ( stanza_t, integer, string ) : stanza_t
+	end
+
+	record serialized_stanza_t
+		name : string
+		attr : { string : string }
+		{ serialized_stanza_t | string }
+	end
+
+	stanza : function ( string, { string : string } ) : stanza_t
+	is_stanza : function ( any ) : boolean
+	preserialize : function ( stanza_t ) : serialized_stanza_t
+	deserialize : function ( serialized_stanza_t ) : stanza_t
+	clone : function ( stanza_t, boolean ) : stanza_t
+	message : function ( { string : string }, string ) : stanza_t
+	iq : function ( { string : string } ) : stanza_t
+	reply : function ( stanza_t ) : stanza_t
+	error_reply : function ( stanza_t, string, string, string, string )
+	presence : function ( { string : string } ) : stanza_t
+	xml_escape : function ( string ) : string
+	pretty_print : function ( string ) : string
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/strbitop.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,6 @@
+local record mod
+	sand : function (string, string) : string
+	sor : function (string, string) : string
+	sxor : function (string, string) : string
+end
+return mod
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/table.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,6 @@
+local record lib
+	create : function (narr:integer, nrec:integer):table
+	pack : function (...:any):{any}
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/time.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,6 @@
+
+local record lib
+	now : function () : number
+	monotonic : function () : number
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/uuid.d.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,8 @@
+local record lib
+	get_nibbles : (number) : string
+	generate : function () : string
+
+	seed : function (string)
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/util/xtemplate.tl	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,101 @@
+-- render(template, stanza) --> string
+-- {path} --> stanza:find(path)
+-- {{ns}name/child|each({ns}name){sub-template}}
+
+--[[
+template ::= "{" path ("|" name ("(" args ")")? (template)? )* "}"
+path ::= defined by util.stanza
+name ::= %w+
+args ::= anything with balanced ( ) pairs
+]]
+
+local s_gsub = string.gsub;
+local s_match = string.match;
+local s_sub = string.sub;
+local t_concat = table.concat;
+
+local st = require "util.stanza";
+
+local type escape_t = function (string) : string
+local type filter_t = function (string, string | st.stanza_t, string) : string | st.stanza_t, boolean
+local type filter_coll = { string : filter_t }
+
+local function render(template : string, root : st.stanza_t, escape : escape_t, filters : filter_coll) : string
+	escape = escape or st.xml_escape;
+
+	return (s_gsub(template, "%b{}", function(block : string) : string
+		local inner = s_sub(block, 2, -2);
+		local path, pipe, pos = s_match(inner, "^([^|]+)(|?)()");
+		if not path is string then return end
+		local value : string | st.stanza_t
+		if path == "." then
+			value = root;
+		elseif path == "#" then
+			value = root:get_text();
+		else
+			value = root:find(path);
+		end
+		local is_escaped = false;
+
+		while pipe == "|" do
+			local func, args, tmpl, p = s_match(inner, "^(%w+)(%b())(%b{})()", pos as integer);
+			if not func then func, args, p = s_match(inner, "^(%w+)(%b())()", pos as integer); end
+			if not func then func, tmpl, p = s_match(inner, "^(%w+)(%b{})()", pos as integer); end
+			if not func then func, p = s_match(inner, "^(%w+)()", pos as integer); end
+			if not func then break end
+			if tmpl then tmpl = s_sub(tmpl, 2, -2); end
+			if args then args = s_sub(args, 2, -2); end
+
+			if func == "each" and tmpl and st.is_stanza(value) then
+				if not args then value, args = root, path; end
+				local ns, name = s_match(args, "^(%b{})(.*)$");
+				if ns then ns = s_sub(ns, 2, -2); else name, ns = args, nil; end
+				if ns == "" then ns = nil; end
+				if name == "" then name = nil; end
+				local out, i = {}, 1;
+				for c in (value as st.stanza_t):childtags(name, ns) do
+					out[i], i = render(tmpl, c, escape, filters), i + 1;
+				end
+				value = t_concat(out);
+				is_escaped = true;
+			elseif func == "and" and tmpl then
+				local condition = value;
+				if args then condition = root:find(args); end
+				if condition then
+					value = render(tmpl, root, escape, filters);
+					is_escaped = true;
+				end
+			elseif func == "or" and tmpl then
+				local condition = value;
+				if args then condition = root:find(args); end
+				if not condition then
+					value = render(tmpl, root, escape, filters);
+					is_escaped = true;
+				end
+			elseif filters and filters[func] then
+				local f = filters[func];
+				if args == nil then
+					value, is_escaped = f(value, tmpl);
+				else
+					value, is_escaped = f(args, value, tmpl);
+				end
+			else
+				error("No such filter function: " .. func);
+			end
+			pipe, pos = s_match(inner, "^(|?)()", p as integer);
+		end
+
+		if value is string then
+			if not is_escaped then value = escape(value); end
+			return value;
+		elseif st.is_stanza(value) then
+			value = value:get_text();
+			if value then
+				return escape(value);
+			end
+		end
+		return "";
+	end));
+end
+
+return { render = render };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/cfgdump.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,128 @@
+#!/usr/bin/env lua
+
+-- cfgdump.lua prosody.cfg.lua [[host] option]
+
+local s_format, print = string.format, print;
+local printf = function(fmt, ...) return print(s_format(fmt, ...)); end
+local it = require "util.iterators";
+local function sort_anything(a, b)
+	local typeof_a, typeof_b = type(a), type(b);
+	if typeof_a ~= typeof_b then return typeof_a < typeof_b end
+	return a < b -- should work for everything in a config file
+end
+local serialization = require "util.serialization";
+local serialize = serialization.new and serialization.new({
+	unquoted = true, table_iterator = function(t) return it.sorted_pairs(t, sort_anything); end,
+}) or serialization.serialize;
+local configmanager = require"core.configmanager";
+local startup = require "util.startup";
+
+startup.set_function_metatable();
+local config_filename, onlyhost, onlyoption = ...;
+
+local ok, _, err = configmanager.load(config_filename or "./prosody.cfg.lua", "lua");
+assert(ok, err);
+
+if onlyhost then
+	if not onlyoption then
+		onlyhost, onlyoption = "*", onlyhost;
+	end
+	if onlyhost ~= "*" then
+		local component_module = configmanager.get(onlyhost, "component_module");
+
+		if component_module == "component" then
+			printf("Component %q", onlyhost);
+		elseif component_module then
+			printf("Component %q %q", onlyhost, component_module);
+		else
+			printf("VirtualHost %q", onlyhost);
+		end
+	end
+	printf("%s = %s", onlyoption or "?", serialize(configmanager.get(onlyhost, onlyoption)));
+	return;
+end
+
+local config = configmanager.getconfig();
+
+
+for host, hostcfg in it.sorted_pairs(config) do
+	local fixed = {};
+	for option, value in it.sorted_pairs(hostcfg) do
+		fixed[option] = value;
+		if option:match("ports?$") or option:match("interfaces?$") then
+			if option:match("s$") then
+				if type(value) ~= "table" then
+					fixed[option] = { value };
+				end
+			else
+				if type(value) == "table" and #value > 1 then
+					fixed[option] = nil;
+					fixed[option.."s"] = value;
+				end
+			end
+		end
+	end
+	config[host] = fixed;
+end
+
+local globals = config["*"]; config["*"] = nil;
+
+local function printsection(section)
+	local out, n = {}, 1;
+	for k,v in it.sorted_pairs(section) do
+		out[n], n = s_format("%s = %s", k, serialize(v)), n + 1;
+	end
+	table.sort(out);
+	print(table.concat(out, "\n"));
+end
+
+print("-------------- Prosody Exported Configuration File -------------");
+print();
+print("------------------------ Global section ------------------------");
+print();
+printsection(globals);
+print();
+
+local has_components = nil;
+
+print("------------------------ Virtual hosts -------------------------");
+
+for host, hostcfg in it.sorted_pairs(config) do
+	setmetatable(hostcfg, nil);
+	hostcfg.defined = nil;
+
+	if hostcfg.component_module == nil then
+		print();
+		printf("VirtualHost %q", host);
+		printsection(hostcfg);
+	else
+		has_components = true
+	end
+end
+
+print();
+
+if has_components then
+print("------------------------- Components ---------------------------");
+
+	for host, hostcfg in it.sorted_pairs(config) do
+		local component_module = hostcfg.component_module;
+		hostcfg.component_module = nil;
+
+		if component_module then
+			print();
+			if component_module == "component" then
+				printf("Component %q", host);
+			else
+				printf("Component %q %q", host, component_module);
+				hostcfg.component_module = nil;
+				hostcfg.load_global_modules = nil;
+			end
+			printsection(hostcfg);
+		end
+	end
+end
+
+print()
+print("------------------------- End of File --------------------------");
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/dnsregistry.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,43 @@
+-- Generate util/dnsregistry.lua from IANA HTTP status code registry
+local xml = require "util.xml";
+local registries = xml.parse(io.read("*a"), { allow_processing_instructions = true });
+
+print("-- Source: https://www.iana.org/assignments/dns-parameters/dns-parameters.xml");
+print(os.date("-- Generated on %Y-%m-%d"))
+
+local registry_mapping = {
+	["dns-parameters-2"] = "classes";
+	["dns-parameters-4"] = "types";
+	["dns-parameters-6"] = "errors";
+};
+
+print("return {");
+for registry in registries:childtags("registry") do
+	local registry_name = registry_mapping[registry.attr.id];
+	if registry_name then
+		print("\t" .. registry_name .. " = {");
+		for record in registry:childtags("record") do
+			local record_name = record:get_child_text("name");
+			local record_type = record:get_child_text("type");
+			local record_desc = record:get_child_text("description");
+			local record_code = tonumber(record:get_child_text("value"));
+
+			if tostring(record):lower():match("reserved") or tostring(record):lower():match("reserved") then
+				record_code = nil;
+			end
+
+			if registry_name == "classes" and record_code then
+				record_type = record_desc and record_desc:match("%((%w+)%)$")
+				if record_type then
+					print(("\t\t[%q] = %d; [%d] = %q;"):format(record_type, record_code, record_code, record_type))
+				end
+			elseif registry_name == "types" and record_type and record_code then
+				print(("\t\t[%q] = %d; [%d] = %q;"):format(record_type, record_code, record_code, record_type))
+			elseif registry_name == "errors" and record_code and record_name then
+				print(("\t\t[%d] = %q; [%q] = %q;"):format(record_code, record_name, record_name, record_desc or record_name));
+			end
+		end
+		print("\t};");
+	end
+end
+print("};");
--- a/tools/ejabberd2prosody.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/tools/ejabberd2prosody.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -85,7 +85,13 @@
 		data.stored_key = hex(unb64(password[2]));
 		data.server_key = hex(unb64(password[3]));
 		data.salt = unb64(password[4]);
-		data.iteration_count = password[5];
+		if type(password[6]) == "number" then
+			assert(password[5] == "sha", "unexpected passwd entry hash: "..tostring(password[5]));
+			data.iteration_count = password[6];
+		else
+			assert(type(password[5]) == "number", "unexpected passwd entry in source data");
+			data.iteration_count = password[5];
+		end
 	end
 	local ret, err = dm.store(node, host, "accounts", data);
 	print("["..(err or "success").."] accounts: "..node.."@"..host);
@@ -181,13 +187,16 @@
 	for _,aff in ipairs(properties.affiliations) do
 		store._affiliations[build_jid(aff[1])] = aff[2][1] or aff[2];
 	end
-	store._data.subject = properties.subject;
+	-- destructure ejabberd's subject datum (e.g. [{text,<<>>,<<"my room subject">>}] )
+	store._data.subject = properties.subject[1][3];
 	if properties.subject_author then
 		store._data.subject_from = store.jid .. "/" .. properties.subject_author;
 	end
 	store._data.name = properties.title;
 	store._data.description = properties.description;
-	store._data.password = properties.password;
+	if properties.password_protected ~= false and properties.password ~= "" then
+		store._data.password = properties.password;
+	end
 	store._data.moderated = (properties.moderated == "true") or nil;
 	store._data.members_only = (properties.members_only == "true") or nil;
 	store._data.persistent = (properties.persistent == "true") or nil;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/form2table.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,48 @@
+-- Read an XML dataform and spit out a serialized Lua table of it
+
+local function from_stanza(stanza)
+	local layout = {
+		title = stanza:get_child_text("title");
+		instructions = stanza:get_child_text("instructions");
+	};
+	for tag in stanza:childtags("field") do
+		local field = {
+			name = tag.attr.var;
+			type = tag.attr.type;
+			label = tag.attr.label;
+			desc = tag:get_child_text("desc");
+			required = tag:get_child("required") and true or nil;
+			value = tag:get_child_text("value");
+			options = nil;
+		};
+
+		if field.type == "list-single" or field.type == "list-multi" then
+			local options = {};
+			for option in tag:childtags("option") do
+				options[#options+1] = { label = option.attr.label, value = option:get_child_text("value") };
+			end
+			field.options = options;
+		end
+
+		if field.type == "jid-multi" or field.type == "list-multi" or field.type == "text-multi" then
+			local values = {};
+			for value in tag:childtags("value") do
+				values[#values+1] = value:get_text();
+			end
+			if field.type == "text-multi" then
+				values = table.concat(values, "\n");
+			end
+			field.value = values;
+		end
+
+		if field.type == "boolean" then
+			field.value = field.value == "true" or field.value == "1";
+		end
+
+		layout[#layout+1] = field;
+
+	end
+	return layout;
+end
+
+print("dataforms.new " .. require "util.serialization".serialize(from_stanza(require "util.xml".parse(io.read("*a"))), { unquoted = true }))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/generate_format_spec.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,52 @@
+local format = require"util.format".format;
+local dump = require"util.serialization".new("oneline")
+local types = {
+	"nil";
+	"boolean";
+	"number";
+	"string";
+	"function";
+	-- "userdata";
+	"thread";
+	"table";
+};
+local example_values = {
+	["nil"] = { n = 1; nil };
+	["boolean"] = { true; false };
+	["number"] = { 97; -12345; 1.5; 73786976294838206464; math.huge; 2147483647 };
+	["string"] = { "hello"; "foo \1\2\3 bar"; "nödåtgärd"; string.sub("nödåtgärd", 1, -4) };
+	["function"] = { function() end };
+	-- ["userdata"] = {};
+	["thread"] = { coroutine.create(function() end) };
+	["table"] = { {}, setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end}) };
+};
+local example_strings = setmetatable({
+	["nil"] = { "nil" };
+	["function"] = { "function() end" };
+	["number"] = { "97"; "-12345"; "1.5"; "73786976294838206464"; "math.huge"; "2147483647" };
+	["thread"] = { "coroutine.create(function() end)" };
+	["table"] = { "{ }", "setmetatable({},{__tostring=function ()return \"foo \\1\\2\\3 bar\"end})" }
+}, { __index = function() return {} end });
+for _, lua_type in ipairs(types) do
+	print(string.format("\t\tdescribe(\"%s\", function ()", lua_type));
+	local examples = example_values[lua_type];
+	for fmt in ("cdiouxXaAeEfgGqs"):gmatch(".") do
+		print(string.format("\t\t\tdescribe(\"to %%%s\", function ()", fmt));
+		print("\t\t\t\tit(\"works\", function ()");
+		for i = 1, examples.n or #examples do
+			local example = examples[i];
+			if not tostring(example):match("%w+: 0[xX]%x+") then
+				print(string.format("\t\t\t\t\tassert.equal(%q, format(%q, %s))", format("%" .. fmt, example), "%" .. fmt,
+					example_strings[lua_type][i] or dump(example)));
+			else
+				print(string.format("\t\t\t\t\tassert.matches(\"[%s: 0[xX]%%x+]\", format(%q, %s))", lua_type, "%" .. fmt,
+					example_strings[lua_type][i] or dump(example)));
+			end
+		end
+		print("\t\t\t\tend);");
+		print("\t\t\tend);");
+		print()
+	end
+	print("\t\tend);");
+	print()
+end
--- a/tools/http-status-codes.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/tools/http-status-codes.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,7 +1,7 @@
 -- Generate net/http/codes.lua from IANA HTTP status code registry
 
 local xml = require "util.xml";
-local registry = xml.parse(io.read("*a"));
+local registry = xml.parse(io.read("*a"), { allow_processing_instructions = true });
 
 io.write([[
 
--- a/tools/jabberd14sql2prosody.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/tools/jabberd14sql2prosody.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -468,7 +468,7 @@
 end
 
 function store_roster(username, host, roster_items)
-	-- fetch current roster-table for username@host if he already has one
+	-- fetch current roster-table for username@host if they already have one
 	local roster = dm.load(username, host, "roster") or {};
 	-- merge imported roster-items with loaded roster
 	for item_tag in roster_items:childtags() do
@@ -508,7 +508,7 @@
 function store_subscription_request(username, host, presence_stanza)
 	local from_bare = presence_stanza.attr.from;
 
-	-- fetch current roster-table for username@host if he already has one
+	-- fetch current roster-table for username@host if they already have one
 	local roster = dm.load(username, host, "roster") or {};
 
 	local item = roster[from_bare];
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/linedebug.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,18 @@
+local data = {}
+local getinfo = debug.getinfo;
+local function linehook(ev, li)
+	local S = getinfo(2, "S");
+	if S and S.source and S.source:match"^@" then
+		local file = S.source:sub(2);
+		local lines = data[file];
+		if not lines then
+			lines = {};
+			data[file] = lines;
+			for line in io.lines(file) do
+				lines[#lines+1] = line;
+			end
+		end
+		io.stderr:write(ev, " ", file, " ", li, " ", lines[li], "\n");
+	end
+end
+debug.sethook(linehook, "l");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/make_repo.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,44 @@
+print("Getting all the available modules")
+if os.execute '[ -e "./downloaded_modules" ]' then
+	os.execute("rm -rf downloaded_modules")
+end
+os.execute("hg clone https://hg.prosody.im/prosody-modules/ downloaded_modules")
+local i, popen = 0, io.popen
+local flag = "mod_"
+if os.execute '[ -e "./repository" ]' then
+	os.execute("mkdir repository")
+end
+local pfile = popen('ls -a "downloaded_modules"')
+for filename in pfile:lines() do
+	i = i + 1
+	if filename:sub(1, #flag) == flag then
+		local file = io.open("repository/"..filename.."-scm-1.rockspec", "w")
+		file:write('package = "'..filename..'"', '\n')
+		file:write('version = "scm-1"', '\n')
+		file:write('source = {', '\n')
+		file:write('\turl = "hg+https://hg.prosody.im/prosody-modules",', '\n')
+		file:write('\tdir = "prosody-modules"', '\n')
+		file:write('}', '\n')
+		file:write('description = {', '\n')
+		file:write('\thomepage = "https://prosody.im/",', '\n')
+		file:write('\tlicense = "MIT"', '\n')
+		file:write('}', '\n')
+		file:write('dependencies = {', '\n')
+		file:write('\t"lua >= 5.1"', '\n')
+		file:write('}', '\n')
+		file:write('build = {', '\n')
+		file:write('\ttype = "builtin",', '\n')
+		file:write('\tmodules = {', '\n')
+		file:write('\t\t["'..filename..'.'..filename..'"] = "'..filename..'/'..filename..'.lua"', '\n')
+		file:write('\t}', '\n')
+		file:write('}', '\n')
+		file:close()
+	end
+end
+pfile:close()
+os.execute("cd repository/ && luarocks-admin make_manifest ./ && chmod -R 644 ./*")
+print("")
+print("Done!. Modules' sources are locally available at ./downloaded_modules")
+print("Repository is available at ./repository")
+print("The repository contains all of prosody modules' respective rockspecs, as well as manifest files and an html Index")
+print("You can now either point your server to this folder, or copy its contents to another configured folder.")
--- a/tools/migration/Makefile	Mon Dec 12 07:03:31 2022 +0100
+++ b/tools/migration/Makefile	Mon Dec 12 07:07:13 2022 +0100
@@ -12,16 +12,12 @@
 INSTALLEDMODULES = $(LIBDIR)/prosody/modules
 INSTALLEDDATA = $(DATADIR)
 
-SOURCE_FILES = migrator/*.lua
-
-all: prosody-migrator.install migrator.cfg.lua.install prosody-migrator.lua $(SOURCE_FILES)
+all: prosody-migrator.install migrator.cfg.lua.install prosody-migrator.lua
 
 install: prosody-migrator.install migrator.cfg.lua.install
-	install -d $(BIN) $(CONFIG) $(SOURCE) $(SOURCE)/migrator
+	install -d $(BIN) $(CONFIG) $(SOURCE)
 	install -d $(MAN)/man1
-	install -d $(SOURCE)/migrator
 	install -m755 ./prosody-migrator.install $(BIN)/prosody-migrator
-	install -m644 $(SOURCE_FILES) $(SOURCE)/migrator
 	test -e $(CONFIG)/migrator.cfg.lua || install -m644 migrator.cfg.lua.install $(CONFIG)/migrator.cfg.lua
 
 clean:
@@ -31,7 +27,9 @@
 prosody-migrator.install: prosody-migrator.lua
 	sed "1s/\blua\b/$(RUNWITH)/; \
 		s|^CFG_SOURCEDIR=.*;$$|CFG_SOURCEDIR='$(INSTALLEDSOURCE)';|; \
-		s|^CFG_CONFIGDIR=.*;$$|CFG_CONFIGDIR='$(INSTALLEDCONFIG)';|;" \
+		s|^CFG_CONFIGDIR=.*;$$|CFG_CONFIGDIR='$(INSTALLEDCONFIG)';|; \
+		s|^CFG_DATADIR=.*;$$|CFG_DATADIR='$(INSTALLEDDATA)';|; \
+		s|^CFG_PLUGINDIR=.*;$$|CFG_PLUGINDIR='$(INSTALLEDMODULES)/';|;" \
 			< prosody-migrator.lua > prosody-migrator.install
 
 migrator.cfg.lua.install: migrator.cfg.lua
--- a/tools/migration/migrator.cfg.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/tools/migration/migrator.cfg.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,12 +1,36 @@
 local data_path = "../../data";
 
+local vhost = {
+	"accounts",
+	"account_details",
+	"roster",
+	"vcard",
+	"private",
+	"blocklist",
+	"privacy",
+	"archive-archive",
+	"offline-archive",
+	"pubsub_nodes-pubsub",
+	"pep-pubsub",
+}
+local muc = {
+	"persistent",
+	"config",
+	"state",
+	"muc_log-archive",
+};
+
 input {
-	type = "prosody_files";
+	hosts = {
+		["example.com"] = vhost;
+		["conference.example.com"] = muc;
+	};
+	type = "internal";
 	path = data_path;
 }
 
 output {
-	type = "prosody_sql";
+	type = "sql";
 	driver = "SQLite3";
 	database = data_path.."/prosody.sqlite";
 }
@@ -14,11 +38,11 @@
 --[[
 
 input {
-	type = "prosody_files";
+	type = "internal";
 	path = data_path;
 }
 output {
-	type = "prosody_sql";
+	type = "sql";
 	driver = "SQLite3";
 	database = data_path.."/prosody.sqlite";
 }
--- a/tools/migration/migrator/mtools.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +0,0 @@
-
-
-local print = print;
-local t_insert = table.insert;
-local t_sort = table.sort;
-
-
-local function sorted(params)
-
-	local reader = params.reader; -- iterator to get items from
-	local sorter = params.sorter; -- sorting function
-	local filter = params.filter; -- filter function
-
-	local cache = {};
-	for item in reader do
-		if filter then item = filter(item); end
-		if item then t_insert(cache, item); end
-	end
-	if sorter then
-		t_sort(cache, sorter);
-	end
-	local i = 0;
-	return function()
-		i = i + 1;
-		return cache[i];
-	end;
-
-end
-
-local function merged(reader, merger)
-
-	local item1 = reader();
-	local merged = { item1 };
-	return function()
-		while true do
-			if not item1 then return nil; end
-			local item2 = reader();
-			if not item2 then item1 = nil; return merged; end
-			if merger(item1, item2) then
-			--print("merged")
-				item1 = item2;
-				t_insert(merged, item1);
-			else
-			--print("unmerged", merged)
-				item1 = item2;
-				local tmp = merged;
-				merged = { item1 };
-				return tmp;
-			end
-		end
-	end;
-
-end
-
-return {
-	sorted = sorted;
-	merged = merged;
-}
--- a/tools/migration/migrator/prosody_files.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,144 +0,0 @@
-
-local print = print;
-local assert = assert;
-local setmetatable = setmetatable;
-local tonumber = tonumber;
-local char = string.char;
-local coroutine = coroutine;
-local lfs = require "lfs";
-local loadfile = loadfile;
-local pcall = pcall;
-local mtools = require "migrator.mtools";
-local next = next;
-local pairs = pairs;
-local json = require "util.json";
-local os_getenv = os.getenv;
-local error = error;
-
-prosody = {};
-local dm = require "util.datamanager"
-
-
-local function is_dir(path) return lfs.attributes(path, "mode") == "directory"; end
-local function is_file(path) return lfs.attributes(path, "mode") == "file"; end
-local function clean_path(path)
-	return path:gsub("\\", "/"):gsub("//+", "/"):gsub("^~", os_getenv("HOME") or "~");
-end
-local encode, decode; do
-	local urlcodes = setmetatable({}, { __index = function (t, k) t[k] = char(tonumber("0x"..k)); return t[k]; end });
-	decode = function (s) return s and (s:gsub("+", " "):gsub("%%([a-fA-F0-9][a-fA-F0-9])", urlcodes)); end
-	encode = function (s) return s and (s:gsub("%W", function (c) return format("%%%02x", c:byte()); end)); end
-end
-local function decode_dir(x)
-	if x:gsub("%%%x%x", ""):gsub("[a-zA-Z0-9]", "") == "" then
-		return decode(x);
-	end
-end
-local function decode_file(x)
-	if x:match(".%.dat$") and x:gsub("%.dat$", ""):gsub("%%%x%x", ""):gsub("[a-zA-Z0-9]", "") == "" then
-		return decode(x:gsub("%.dat$", ""));
-	end
-end
-local function prosody_dir(path, ondir, onfile, ...)
-	for x in lfs.dir(path) do
-		local xpath = path.."/"..x;
-		if decode_dir(x) and is_dir(xpath) then
-			ondir(xpath, x, ...);
-		elseif decode_file(x) and is_file(xpath) then
-			onfile(xpath, x, ...);
-		end
-	end
-end
-
-local function handle_root_file(path, name)
-	--print("root file: ", decode_file(name))
-	coroutine.yield { user = nil, host = nil, store = decode_file(name) };
-end
-local function handle_host_file(path, name, host)
-	--print("host file: ", decode_dir(host).."/"..decode_file(name))
-	coroutine.yield { user = nil, host = decode_dir(host), store = decode_file(name) };
-end
-local function handle_store_file(path, name, store, host)
-	--print("store file: ", decode_file(name).."@"..decode_dir(host).."/"..decode_dir(store))
-	coroutine.yield { user = decode_file(name), host = decode_dir(host), store = decode_dir(store) };
-end
-local function handle_host_store(path, name, host)
-	prosody_dir(path, function() end, handle_store_file, name, host);
-end
-local function handle_host_dir(path, name)
-	prosody_dir(path, handle_host_store, handle_host_file, name);
-end
-local function handle_root_dir(path)
-	prosody_dir(path, handle_host_dir, handle_root_file);
-end
-
-local function decode_user(item)
-	local userdata = {
-		user = item[1].user;
-		host = item[1].host;
-		stores = {};
-	};
-	for i=1,#item do -- loop over stores
-		local result = {};
-		local store = item[i];
-		userdata.stores[store.store] = store.data;
-		store.user = nil; store.host = nil; store.store = nil;
-	end
-	return userdata;
-end
-
-local function reader(input)
-	local path = clean_path(assert(input.path, "no input.path specified"));
-	assert(is_dir(path), "input.path is not a directory");
-	local iter = coroutine.wrap(function()handle_root_dir(path);end);
-	-- get per-user stores, sorted
-	local iter = mtools.sorted {
-		reader = function()
-			local x = iter();
-			while x do
-				dm.set_data_path(path);
-				local err;
-				x.data, err = dm.load(x.user, x.host, x.store);
-				if x.data == nil and err then
-					local p = dm.getpath(x.user, x.host, x.store);
-					print(("Error loading data at path %s for %s@%s (%s store): %s")
-						:format(p, x.user or "<nil>", x.host or "<nil>", x.store or "<nil>", err or "<nil>"));
-				else
-					return x;
-				end
-				x = iter();
-			end
-		end;
-		sorter = function(a, b)
-			local a_host, a_user, a_store = a.host or "", a.user or "", a.store or "";
-			local b_host, b_user, b_store = b.host or "", b.user or "", b.store or "";
-			return a_host > b_host or (a_host==b_host and a_user > b_user) or (a_host==b_host and a_user==b_user and a_store > b_store);
-		end;
-	};
-	-- merge stores to get users
-	iter = mtools.merged(iter, function(a, b)
-		return (a.host == b.host and a.user == b.user);
-	end);
-
-	return function()
-		local x = iter();
-		return x and decode_user(x);
-	end
-end
-
-local function writer(output)
-	local path = clean_path(assert(output.path, "no output.path specified"));
-	assert(is_dir(path), "output.path is not a directory");
-	return function(item)
-		if not item then return; end -- end of input
-		dm.set_data_path(path);
-		for store, data in pairs(item.stores) do
-			assert(dm.store(item.user, item.host, store, data));
-		end
-	end
-end
-
-return {
-	reader = reader;
-	writer = writer;
-}
--- a/tools/migration/migrator/prosody_sql.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,190 +0,0 @@
-
-local assert = assert;
-local have_DBI = pcall(require,"DBI");
-local print = print;
-local type = type;
-local next = next;
-local pairs = pairs;
-local t_sort = table.sort;
-local json = require "util.json";
-local mtools = require "migrator.mtools";
-local tostring = tostring;
-local tonumber = tonumber;
-
-if not have_DBI then
-	error("LuaDBI (required for SQL support) was not found, please see https://prosody.im/doc/depends#luadbi", 0);
-end
-
-local sql = require "util.sql";
-
-local function create_table(engine, name) -- luacheck: ignore 431/engine
-	local Table, Column, Index = sql.Table, sql.Column, sql.Index;
-
-	local ProsodyTable = Table {
-		name= name or "prosody";
-		Column { name="host", type="TEXT", nullable=false };
-		Column { name="user", type="TEXT", nullable=false };
-		Column { name="store", type="TEXT", nullable=false };
-		Column { name="key", type="TEXT", nullable=false };
-		Column { name="type", type="TEXT", nullable=false };
-		Column { name="value", type="MEDIUMTEXT", nullable=false };
-		Index { name="prosody_index", "host", "user", "store", "key" };
-	};
-	engine:transaction(function()
-		ProsodyTable:create(engine);
-	end);
-
-end
-
-local function serialize(value)
-	local t = type(value);
-	if t == "string" or t == "boolean" or t == "number" then
-		return t, tostring(value);
-	elseif t == "table" then
-		local value,err = json.encode(value);
-		if value then return "json", value; end
-		return nil, err;
-	end
-	return nil, "Unhandled value type: "..t;
-end
-local function deserialize(t, value)
-	if t == "string" then return value;
-	elseif t == "boolean" then
-		if value == "true" then return true;
-		elseif value == "false" then return false; end
-	elseif t == "number" then return tonumber(value);
-	elseif t == "json" then
-		return json.decode(value);
-	end
-end
-
-local function decode_user(item)
-	local userdata = {
-		user = item[1][1].user;
-		host = item[1][1].host;
-		stores = {};
-	};
-	for i=1,#item do -- loop over stores
-		local result = {};
-		local store = item[i];
-		for i=1,#store do -- loop over store data
-			local row = store[i];
-			local k = row.key;
-			local v = deserialize(row.type, row.value);
-			if k and v then
-				if k ~= "" then result[k] = v; elseif type(v) == "table" then
-					for a,b in pairs(v) do
-						result[a] = b;
-					end
-				end
-			end
-			userdata.stores[store[1].store] = result;
-		end
-	end
-	return userdata;
-end
-
-local function needs_upgrade(engine, params)
-	if params.driver == "MySQL" then
-		local success = engine:transaction(function()
-			local result = engine:execute("SHOW COLUMNS FROM prosody WHERE Field='value' and Type='text'");
-			assert(result:rowcount() == 0);
-
-			-- COMPAT w/pre-0.10: Upgrade table to UTF-8 if not already
-			local check_encoding_query = [[
-			SELECT "COLUMN_NAME","COLUMN_TYPE","TABLE_NAME"
-			FROM "information_schema"."columns"
-			WHERE "TABLE_NAME" LIKE 'prosody%%' AND ( "CHARACTER_SET_NAME"!='%s' OR "COLLATION_NAME"!='%s_bin' );
-			]];
-			check_encoding_query = check_encoding_query:format(engine.charset, engine.charset);
-			local result = engine:execute(check_encoding_query);
-			assert(result:rowcount() == 0)
-		end);
-		if not success then
-			-- Upgrade required
-			return true;
-		end
-	end
-	return false;
-end
-
-local function reader(input)
-	local engine = assert(sql:create_engine(input, function (engine) -- luacheck: ignore 431/engine
-		if needs_upgrade(engine, input) then
-			error("Old database format detected. Please run: prosodyctl mod_storage_sql upgrade");
-		end
-	end));
-	local keys = {"host", "user", "store", "key", "type", "value"};
-	assert(engine:connect());
-	local f,s,val = assert(engine:select("SELECT \"host\", \"user\", \"store\", \"key\", \"type\", \"value\" FROM \"prosody\";"));
-	-- get SQL rows, sorted
-	local iter = mtools.sorted {
-		reader = function() val = f(s, val); return val; end;
-		filter = function(x)
-			for i=1,#keys do
-				x[ keys[i] ] = x[i];
-			end
-			if x.host  == "" then x.host  = nil; end
-			if x.user  == "" then x.user  = nil; end
-			if x.store == "" then x.store = nil; end
-			return x;
-		end;
-		sorter = function(a, b)
-			local a_host, a_user, a_store = a.host or "", a.user or "", a.store or "";
-			local b_host, b_user, b_store = b.host or "", b.user or "", b.store or "";
-			return a_host > b_host or (a_host==b_host and a_user > b_user) or (a_host==b_host and a_user==b_user and a_store > b_store);
-		end;
-	};
-	-- merge rows to get stores
-	iter = mtools.merged(iter, function(a, b)
-		return (a.host == b.host and a.user == b.user and a.store == b.store);
-	end);
-	-- merge stores to get users
-	iter = mtools.merged(iter, function(a, b)
-		return (a[1].host == b[1].host and a[1].user == b[1].user);
-	end);
-	return function()
-		local x = iter();
-		return x and decode_user(x);
-	end;
-end
-
-local function writer(output, iter)
-	local engine = assert(sql:create_engine(output, function (engine) -- luacheck: ignore 431/engine
-		if needs_upgrade(engine, output) then
-			error("Old database format detected. Please run: prosodyctl mod_storage_sql upgrade");
-		end
-		create_table(engine);
-	end));
-	assert(engine:connect());
-	assert(engine:delete("DELETE FROM \"prosody\""));
-	local insert_sql = "INSERT INTO \"prosody\" (\"host\",\"user\",\"store\",\"key\",\"type\",\"value\") VALUES (?,?,?,?,?,?)";
-
-	return function(item)
-		if not item then assert(engine.conn:commit()) return end -- end of input
-		local host = item.host or "";
-		local user = item.user or "";
-		for store, data in pairs(item.stores) do
-			-- TODO transactions
-			local extradata = {};
-			for key, value in pairs(data) do
-				if type(key) == "string" and key ~= "" then
-					local t, value = assert(serialize(value));
-					local ok, err = assert(engine:insert(insert_sql, host, user, store, key, t, value));
-				else
-					extradata[key] = value;
-				end
-			end
-			if next(extradata) ~= nil then
-				local t, extradata = assert(serialize(extradata));
-				local ok, err = assert(engine:insert(insert_sql, host, user, store, "", t, extradata));
-			end
-		end
-	end;
-end
-
-
-return {
-	reader = reader;
-	writer = writer;
-}
--- a/tools/migration/prosody-migrator.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/tools/migration/prosody-migrator.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,41 +1,83 @@
 #!/usr/bin/env lua
 
-CFG_SOURCEDIR=os.getenv("PROSODY_SRCDIR");
-CFG_CONFIGDIR=os.getenv("PROSODY_CFGDIR");
+CFG_SOURCEDIR=CFG_SOURCEDIR or os.getenv("PROSODY_SRCDIR");
+CFG_CONFIGDIR=CFG_CONFIGDIR or os.getenv("PROSODY_CFGDIR");
+CFG_PLUGINDIR=CFG_PLUGINDIR or os.getenv("PROSODY_PLUGINDIR");
+CFG_DATADIR=CFG_DATADIR or os.getenv("PROSODY_DATADIR");
 
--- Substitute ~ with path to home directory in paths
-if CFG_CONFIGDIR then
-	CFG_CONFIGDIR = CFG_CONFIGDIR:gsub("^~", os.getenv("HOME"));
+-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
+
+local function is_relative(path)
+	local path_sep = package.config:sub(1,1);
+        return ((path_sep == "/" and path:sub(1,1) ~= "/")
+	or (path_sep == "\\" and (path:sub(1,1) ~= "/" and path:sub(2,3) ~= ":\\")))
 end
 
+-- Tell Lua where to find our libraries
 if CFG_SOURCEDIR then
-	CFG_SOURCEDIR = CFG_SOURCEDIR:gsub("^~", os.getenv("HOME"));
+	local function filter_relative_paths(path)
+		if is_relative(path) then return ""; end
+	end
+	local function sanitise_paths(paths)
+		return (paths:gsub("[^;]+;?", filter_relative_paths):gsub(";;+", ";"));
+	end
+	package.path = sanitise_paths(CFG_SOURCEDIR.."/?.lua;"..package.path);
+	package.cpath = sanitise_paths(CFG_SOURCEDIR.."/?.so;"..package.cpath);
+end
+
+-- Substitute ~ with path to home directory in data path
+if CFG_DATADIR then
+	if os.getenv("HOME") then
+		CFG_DATADIR = CFG_DATADIR:gsub("^~", os.getenv("HOME"));
+	end
 end
 
 local default_config = (CFG_CONFIGDIR or ".").."/migrator.cfg.lua";
 
--- Command-line parsing
-local options = {};
-local i = 1;
-while arg[i] do
-	if arg[i]:sub(1,2) == "--" then
-		local opt, val = arg[i]:match("([%w-]+)=?(.*)");
-		if opt then
-			options[(opt:sub(3):gsub("%-", "_"))] = #val > 0 and val or true;
-		end
-		table.remove(arg, i);
-	else
-		i = i + 1;
+local function usage()
+	print("Usage: " .. arg[0] .. " [OPTIONS] FROM_STORE TO_STORE");
+	print("  --config FILE         Specify config file")
+	print("  --keep-going          Keep going in case of errors");
+	print("  -v, --verbose         Increase log-level");
+	print("");
+	print("If no stores are specified, 'input' and 'output' are used.");
+end
+
+local startup = require "util.startup";
+do
+	startup.parse_args({
+		short_params = { v = "verbose", h = "help", ["?"] = "help" };
+		value_params = { config = true };
+	});
+	startup.init_global_state();
+	prosody.process_type = "migrator";
+	if prosody.opts.help then
+		usage();
+		os.exit(0);
+	end
+	startup.force_console_logging();
+	startup.init_logging();
+	startup.init_gc();
+	startup.init_errors();
+	startup.setup_plugindir();
+	startup.setup_plugin_install_path();
+	startup.setup_datadir();
+	startup.chdir();
+	startup.read_version();
+	startup.switch_user();
+	startup.check_dependencies();
+	startup.log_startup_warnings();
+	prosody.config_loaded = true;
+	startup.load_libraries();
+	startup.init_http_client();
+	prosody.core_post_stanza = function ()
+		-- silence assert in core.moduleapi
+		error("Attempt to send stanzas from inside migrator.", 0);
 	end
 end
 
-if CFG_SOURCEDIR then
-	package.path = CFG_SOURCEDIR.."/?.lua;"..package.path;
-	package.cpath = CFG_SOURCEDIR.."/?.so;"..package.cpath;
-else
-	package.path = "../../?.lua;"..package.path
-	package.cpath = "../../?.so;"..package.cpath
-end
+-- Command-line parsing
+local options = prosody.opts;
 
 local envloadfile = require "util.envload".envloadfile;
 
@@ -69,28 +111,17 @@
 	print("Error: Output store '"..to_store.."' not found in the config file.");
 end
 
-function load_store_handler(name)
-	local store_type = config[name].type;
-	if not store_type then
-		print("Error: "..name.." store type not specified in the config file");
-		return false;
-	else
-		local ok, err = pcall(require, "migrator."..store_type);
-		if not ok then
-			print(("Error: Failed to initialize '%s' store:\n\t%s")
-				:format(name, err));
-			return false;
-		end
+for store, conf in pairs(config) do -- COMPAT
+	if conf.type == "prosody_files" then
+		conf.type = "internal";
+	elseif conf.type == "prosody_sql" then
+		conf.type = "sql";
 	end
-	return true;
 end
 
-have_err = have_err or not(load_store_handler(from_store, "input") and load_store_handler(to_store, "output"));
-
 if have_err then
 	print("");
-	print("Usage: "..arg[0].." FROM_STORE TO_STORE");
-	print("If no stores are specified, 'input' and 'output' are used.");
+	usage();
 	print("");
 	print("The available stores in your migrator config are:");
 	print("");
@@ -101,17 +132,126 @@
 	os.exit(1);
 end
 
-local itype = config[from_store].type;
-local otype = config[to_store].type;
-local reader = require("migrator."..itype).reader(config[from_store]);
-local writer = require("migrator."..otype).writer(config[to_store]);
+local async = require "util.async";
+local server = require "net.server";
+local watchers = {
+	error = function (_, err)
+		error(err);
+	end;
+	waiting = function ()
+		server.loop();
+	end;
+};
+
+local cm = require "core.configmanager";
+local hm = require "core.hostmanager";
+local sm = require "core.storagemanager";
+local um = require "core.usermanager";
+
+local function users(store, host)
+	if store.users then
+		log("debug", "Using store user iterator")
+		return store:users();
+	else
+		log("debug", "Using usermanager user iterator")
+		return um.users(host);
+	end
+end
+
+local function prepare_config(host, conf)
+	if conf.type == "internal" then
+		sm.olddm.set_data_path(conf.path or prosody.paths.data);
+	elseif conf.type == "sql" then
+		cm.set(host, "sql", conf);
+	end
+end
+
+local function get_driver(host, conf)
+	prepare_config(host, conf);
+	return assert(sm.load_driver(host, conf.type));
+end
 
-local json = require "util.json";
+local migrate_once = {
+	keyval = function(origin, destination, user)
+		local data, err = origin:get(user);
+		assert(not err, err);
+		assert(destination:set(user, data));
+	end;
+	archive = function(origin, destination, user)
+		local iter, err = origin:find(user);
+		assert(iter, err);
+		for id, item, when, with in iter do
+			assert(destination:append(user, id, item, when, with));
+		end
+	end;
+}
+migrate_once.pubsub = function(origin, destination, user, prefix, input_driver, output_driver)
+	if not user and prefix == "pubsub_" then return end
+	local data, err = origin:get(user);
+	assert(not err, err);
+	if not data then return end
+	assert(destination:set(user, data));
+	if prefix == "pubsub_" then user = nil end
+	for node in pairs(data) do
+		local pep_origin = assert(input_driver:open(prefix .. node, "archive"));
+		local pep_destination = assert(output_driver:open(prefix .. node, "archive"));
+		migrate_once.archive(pep_origin, pep_destination, user);
+	end
+end
+
+if options["keep-going"] then
+	local xpcall = require "util.xpcall".xpcall;
+	for t, f in pairs(migrate_once) do
+		migrate_once[t] = function (origin, destination, user, ...)
+			local function log_err(err)
+				if user then
+					log("error", "Error migrating data for user %q: %s", user, err);
+				else
+					log("error", "Error migrating data for host: %s", err);
+				end
+				log("debug", "%s", debug.traceback(nil, 2));
+			end
+			xpcall(f, log_err, origin, destination, user, ...);
+		end
+	end
+end
+
+local migration_runner = async.runner(function (job)
+	for host, stores in pairs(job.input.hosts) do
+		prosody.hosts[host] = startup.make_host(host);
+		sm.initialize_host(host);
+		um.initialize_host(host);
+
+		local input_driver = get_driver(host, job.input);
+
+		local output_driver = get_driver(host, job.output);
+
+		for _, store in ipairs(stores) do
+			local p, typ = store:match("()%-(%w+)$");
+			if typ then store = store:sub(1, p-1); else typ = "keyval"; end
+			log("info", "Migrating host %s store %s (%s)", host, store, typ);
+
+			local migrate = assert(migrate_once[typ], "Unknown store type: "..typ);
+
+			local prefix = store .. "_";
+			if typ == "pubsub" then typ = "keyval"; end
+			if store == "pubsub_nodes" then prefix = "pubsub_"; end
+
+			local origin = assert(input_driver:open(store, typ));
+			local destination = assert(output_driver:open(store, typ));
+
+			migrate(origin, destination, nil, prefix, input_driver, output_driver); -- host data
+
+			for user in users(origin, host) do
+				log("info", "Migrating user %s@%s store %s (%s)", user, host, store, typ);
+				migrate(origin, destination, user, prefix, input_driver, output_driver);
+			end
+		end
+	end
+end, watchers);
 
 io.stderr:write("Migrating...\n");
-for x in reader do
-	--print(json.encode(x))
-	writer(x);
-end
-writer(nil); -- close
+
+migration_runner:run({ input = config[from_store], output = config[to_store] });
+
 io.stderr:write("Done!\n");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/modtrace.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,159 @@
+-- Trace module calls and method calls on created objects
+--
+-- Very rough and for debugging purposes only. It makes many
+-- assumptions and there are many ways it could fail.
+--
+-- Example use:
+--
+--   local dbuffer = require "tools.modtrace".trace("util.dbuffer");
+--
+
+local t_pack = require "util.table".pack;
+local serialize = require "util.serialization".serialize;
+local unpack = table.unpack or unpack; --luacheck: ignore 113
+local set = require "util.set";
+
+local serialize_cfg = {
+	preset = "oneline";
+	freeze = true;
+	fatal = false;
+	fallback = function (v) return "<"..tostring(v)..">" end;
+};
+
+local function stringify_value(v)
+	if type(v) == "string" and #v > 20 then
+		return ("<string(%d)>"):format(#v);
+	elseif type(v) == "function" then
+		return tostring(v);
+	end
+	return serialize(v, serialize_cfg);
+end
+
+local function stringify_params(...)
+	local n = select("#", ...);
+	local r = {};
+	for i = 1, n do
+		table.insert(r, stringify_value((select(i, ...))));
+	end
+	return table.concat(r, ", ");
+end
+
+local function stringify_result(ret)
+	local r = {};
+	for i = 1, ret.n do
+		table.insert(r, stringify_value(ret[i]));
+	end
+	return table.concat(r, ", ");
+end
+
+local function stringify_call(method_name, ...)
+	return ("%s(%s)"):format(method_name, stringify_params(...));
+end
+
+local function wrap_method(original_obj, original_method, method_name)
+	method_name = ("<%s>:%s"):format(getmetatable(original_obj).__name or "object", method_name);
+	return function (new_obj_self, ...)
+		local opts = new_obj_self._modtrace_opts;
+		local f = opts.output or io.stderr;
+		f:write(stringify_call(method_name, ...));
+		local ret = t_pack(original_method(original_obj, ...));
+		if ret.n > 0 then
+			f:write(" = ", stringify_result(ret), "\n");
+		else
+			f:write("\n");
+		end
+		return unpack(ret, 1, ret.n);
+	end;
+end
+
+local function wrap_function(original_function, function_name, opts)
+	local f = opts.output or io.stderr;
+	return function (...)
+		f:write(stringify_call(function_name, ...));
+		local ret = t_pack(original_function(...));
+		if ret.n > 0 then
+			f:write(" = ", stringify_result(ret), "\n");
+		else
+			f:write("\n");
+		end
+		return unpack(ret, 1, ret.n);
+	end;
+end
+
+local function wrap_metamethod(name, method)
+	if name == "__index" then
+		return function (new_obj, k)
+			local original_method;
+			if type(method) == "table" then
+				original_method = new_obj._modtrace_original_obj[k];
+			else
+				original_method = method(new_obj._modtrace_original_obj, k);
+			end
+			if original_method == nil then
+				return nil;
+			end
+			return wrap_method(new_obj._modtrace_original_obj, original_method, k);
+		end;
+	end
+	return function (new_obj, ...)
+		return method(new_obj._modtrace_original_obj, ...);
+	end;
+end
+
+local function wrap_mt(original_mt)
+	local new_mt = {};
+	for k, v in pairs(original_mt) do
+		new_mt[k] = wrap_metamethod(k, v);
+	end
+	return new_mt;
+end
+
+local function wrap_obj(original_obj, opts)
+	local new_mt = wrap_mt(getmetatable(original_obj));
+	return setmetatable({_modtrace_original_obj = original_obj, _modtrace_opts = opts}, new_mt);
+end
+
+local function wrap_new(original_new, function_name, opts)
+	local f = opts.output or io.stderr;
+	return function (...)
+		f:write(stringify_call(function_name, ...));
+		local ret = t_pack(original_new(...));
+		local obj = ret[1];
+
+		if ret.n == 1 and type(ret[1]) == "table" then
+			f:write(" = <", getmetatable(ret[1]).__name or "object", ">", "\n");
+		elseif ret.n > 0 then
+			f:write(" = ", stringify_result(ret), "\n");
+		else
+			f:write("\n");
+		end
+
+		if obj then
+			ret[1] = wrap_obj(obj, opts);
+		end
+		return unpack(ret, 1, ret.n);
+	end;
+end
+
+local function trace(module, opts)
+	if type(module) == "string" then
+		module = require(module);
+	end
+	opts = opts or {};
+	local new_methods = set.new(opts.new_methods or {"new"});
+	local fake_module = setmetatable({}, {
+		__index = function (_, k)
+			if new_methods:contains(k) then
+				return wrap_new(module[k], k, opts);
+			else
+				return wrap_function(module[k], k, opts);
+			end
+		end;
+	});
+	return fake_module;
+end
+
+return {
+	wrap = trace;
+	trace = trace;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/tb2err	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,21 @@
+#!/usr/bin/env lua-any
+-- Lua-Versions: 5.3 5.2 5.1
+-- traceback to errors.err for vim -q
+local path_sep = package.config:sub(1,1);
+for line in io.lines() do
+	local src, err = line:match("%s*(%S+)(:%d+: .*)")
+	if src then
+		src = src:gsub("\\", path_sep);
+		local cut = src:match("/()core/")
+			or src:match("/()net/")
+			or src:match("/()util/")
+			or src:match("/()modules/")
+			or src:match("/()plugins/")
+			or src:match("/()prosody[ctl]*$") 
+		if cut then
+			src = src:sub(cut);
+		end
+		src = src:gsub("^modules/", "plugins/")
+		io.write(src, err, "\n");
+	end
+end
--- a/tools/xep227toprosody.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,269 +0,0 @@
-#!/usr/bin/env lua
--- Prosody IM
--- Copyright (C) 2008-2009 Matthew Wild
--- Copyright (C) 2008-2009 Waqas Hussain
--- Copyright (C) 2010      Stefan Gehn
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
--- FIXME: XEP-0227 supports XInclude but luaexpat does not
---
--- XEP-227 elements and their current level of support:
--- Hosts : supported
--- Users : supported
--- Rosters : supported, needs testing
--- Offline Messages : supported, needs testing
--- Private XML Storage : supported, needs testing
--- vCards : supported, needs testing
--- Privacy Lists: UNSUPPORTED
---   http://xmpp.org/extensions/xep-0227.html#privacy-lists
---   mod_privacy uses dm.load(username, host, "privacy"); and stores stanzas 1:1
--- Incoming Subscription Requests : supported
-
-package.path = package.path..";../?.lua";
-package.cpath = package.cpath..";../?.so"; -- needed for util.pposix used in datamanager
-
-local my_name = arg[0];
-if my_name:match("[/\\]") then
-	package.path = package.path..";"..my_name:gsub("[^/\\]+$", "../?.lua");
-	package.cpath = package.cpath..";"..my_name:gsub("[^/\\]+$", "../?.so");
-end
-
--- ugly workaround for getting datamanager to work outside of prosody :(
-prosody = { };
-prosody.platform = "unknown";
-if os.getenv("WINDIR") then
-	prosody.platform = "windows";
-elseif package.config:sub(1,1) == "/" then
-	prosody.platform = "posix";
-end
-
-local lxp = require "lxp";
-local st = require "util.stanza";
-local xmppstream = require "util.xmppstream";
-local new_xmpp_handlers = xmppstream.new_sax_handlers;
-local dm = require "util.datamanager"
-dm.set_data_path("data");
-
-local ns_separator = xmppstream.ns_separator;
-local ns_pattern = xmppstream.ns_pattern;
-
-local xmlns_xep227 = "http://www.xmpp.org/extensions/xep-0227.html#ns";
-
------------------------------------------------------------------------
-
-function store_vcard(username, host, stanza)
-	-- create or update vCard for username@host
-	local ret, err = dm.store(username, host, "vcard", st.preserialize(stanza));
-	print("["..(err or "success").."] stored vCard: "..username.."@"..host);
-end
-
-function store_password(username, host, password)
-	-- create or update account for username@host
-	local ret, err = dm.store(username, host, "accounts", {password = password});
-	print("["..(err or "success").."] stored account: "..username.."@"..host.." = "..password);
-end
-
-function store_roster(username, host, roster_items)
-	-- fetch current roster-table for username@host if he already has one
-	local roster = dm.load(username, host, "roster") or {};
-	-- merge imported roster-items with loaded roster
-	for item_tag in roster_items:childtags("item") do
-		-- jid for this roster-item
-		local item_jid = item_tag.attr.jid
-		-- validate item stanzas
-		if (item_jid ~= "") then
-			-- prepare roster item
-			-- TODO: is the subscription attribute optional?
-			local item = {subscription = item_tag.attr.subscription, groups = {}};
-			-- optional: give roster item a real name
-			if item_tag.attr.name then
-				item.name = item_tag.attr.name;
-			end
-			-- optional: iterate over group stanzas inside item stanza
-			for group_tag in item_tag:childtags("group") do
-				local group_name = group_tag:get_text();
-				if (group_name ~= "") then
-					item.groups[group_name] = true;
-				else
-					print("[error] invalid group stanza: "..group_tag:pretty_print());
-				end
-			end
-			-- store item in roster
-			roster[item_jid] = item;
-			print("[success] roster entry: " ..username.."@"..host.." - "..item_jid);
-		else
-			print("[error] invalid roster stanza: " ..item_tag:pretty_print());
-		end
-
-	end
-	-- store merged roster-table
-	local ret, err = dm.store(username, host, "roster", roster);
-	print("["..(err or "success").."] stored roster: " ..username.."@"..host);
-end
-
-function store_private(username, host, private_items)
-	local private = dm.load(username, host, "private") or {};
-	for _, ch in ipairs(private_items.tags) do
-		--print("private :"..ch:pretty_print());
-		private[ch.name..":"..ch.attr.xmlns] = st.preserialize(ch);
-		print("[success] private item: " ..username.."@"..host.." - "..ch.name);
-	end
-	local ret, err = dm.store(username, host, "private", private);
-	print("["..(err or "success").."] stored private: " ..username.."@"..host);
-end
-
-function store_offline_messages(username, host, offline_messages)
-	-- TODO: maybe use list_load(), append and list_store() instead
-	--       of constantly reopening the file with list_append()?
-	for ch in offline_messages:childtags("message", "jabber:client") do
-		--print("message :"..ch:pretty_print());
-		local ret, err = dm.list_append(username, host, "offline", st.preserialize(ch));
-		print("["..(err or "success").."] stored offline message: " ..username.."@"..host.." - "..ch.attr.from);
-	end
-end
-
-
-function store_subscription_request(username, host, presence_stanza)
-	local from_bare = presence_stanza.attr.from;
-
-	-- fetch current roster-table for username@host if he already has one
-	local roster = dm.load(username, host, "roster") or {};
-
-	local item = roster[from_bare];
-	if item and (item.subscription == "from" or item.subscription == "both") then
-		return; -- already subscribed, do nothing
-	end
-
-	-- add to table of pending subscriptions
-	if not roster.pending then roster.pending = {}; end
-	roster.pending[from_bare] = true;
-
-	-- store updated roster-table
-	local ret, err = dm.store(username, host, "roster", roster);
-	print("["..(err or "success").."] stored subscription request: " ..username.."@"..host.." - "..from_bare);
-end
-
------------------------------------------------------------------------
-
-local curr_host = "";
-local user_name = "";
-
-
-local cb = {
-	stream_tag = "user",
-	stream_ns = xmlns_xep227,
-};
-function cb.streamopened(session, attr)
-	session.notopen = false;
-	user_name = attr.name;
-	store_password(user_name, curr_host, attr.password);
-end
-function cb.streamclosed(session)
-	session.notopen = true;
-	user_name = "";
-end
-function cb.handlestanza(session, stanza)
-	--print("Parsed stanza "..stanza.name.." xmlns: "..(stanza.attr.xmlns or ""));
-	if (stanza.name == "vCard") and (stanza.attr.xmlns == "vcard-temp") then
-		store_vcard(user_name, curr_host, stanza);
-	elseif (stanza.name == "query") then
-		if (stanza.attr.xmlns == "jabber:iq:roster") then
-			store_roster(user_name, curr_host, stanza);
-		elseif (stanza.attr.xmlns == "jabber:iq:private") then
-			store_private(user_name, curr_host, stanza);
-		end
-	elseif (stanza.name == "offline-messages") then
-		store_offline_messages(user_name, curr_host, stanza);
-	elseif (stanza.name == "presence") and (stanza.attr.xmlns == "jabber:client") then
-		store_subscription_request(user_name, curr_host, stanza);
-	else
-		print("UNHANDLED stanza "..stanza.name.." xmlns: "..(stanza.attr.xmlns or ""));
-	end
-end
-
-local user_handlers = new_xmpp_handlers({ notopen = true }, cb);
-
------------------------------------------------------------------------
-
-local lxp_handlers = {
-	--count = 0
-};
-
--- TODO: error handling for invalid opening elements if curr_host is empty
-function lxp_handlers.StartElement(parser, elementname, attributes)
-	local curr_ns, name = elementname:match(ns_pattern);
-	if name == "" then
-		curr_ns, name = "", curr_ns;
-	end
-	--io.write("+ ", string.rep(" ", count), name, "  (", curr_ns, ")", "\n")
-	--count = count + 1;
-	if curr_host ~= "" then
-		-- forward to xmlhandlers
-		user_handlers.StartElement(parser, elementname, attributes);
-	elseif (curr_ns == xmlns_xep227) and (name == "host") then
-		curr_host = attributes["jid"]; -- start of host element
-		print("Begin parsing host "..curr_host);
-	elseif (curr_ns ~= xmlns_xep227) or (name ~= "server-data") then
-		io.stderr:write("Unhandled XML element: ", name, "\n");
-		os.exit(1);
-	end
-end
-
--- TODO: error handling for invalid closing elements if host is empty
-function lxp_handlers.EndElement(parser, elementname)
-	local curr_ns, name = elementname:match(ns_pattern);
-	if name == "" then
-		curr_ns, name = "", curr_ns;
-	end
-	--count = count - 1;
-	--io.write("- ", string.rep(" ", count), name, "  (", curr_ns, ")", "\n")
-	if curr_host ~= "" then
-		if (curr_ns == xmlns_xep227) and (name == "host") then
-			print("End parsing host "..curr_host);
-			curr_host = "" -- end of host element
-		else
-			-- forward to xmlhandlers
-			user_handlers.EndElement(parser, elementname);
-		end
-	elseif (curr_ns ~= xmlns_xep227) or (name ~= "server-data") then
-		io.stderr:write("Unhandled XML element: ", name, "\n");
-		os.exit(1);
-	end
-end
-
-function lxp_handlers.CharacterData(parser, string)
-	if curr_host ~= "" then
-		-- forward to xmlhandlers
-		user_handlers.CharacterData(parser, string);
-	end
-end
-
------------------------------------------------------------------------
-
-local arg = ...;
-local help = "/? -? ? /h -h /help -help --help";
-if not arg or help:find(arg, 1, true) then
-	print([[XEP-227 importer for Prosody
-
-  Usage: xep227toprosody.lua filename.xml
-
-]]);
-	os.exit(1);
-end
-
-local file = io.open(arg);
-if not file then
-	io.stderr:write("Could not open file: ", arg, "\n");
-	os.exit(0);
-end
-
-local parser = lxp.new(lxp_handlers, ns_separator);
-for l in file:lines() do
-	parser:parse(l);
-end
-parser:parse();
-parser:close();
-file:close();
--- a/util-src/GNUmakefile	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/GNUmakefile	Mon Dec 12 07:07:13 2022 +0100
@@ -7,7 +7,8 @@
 TARGET?=../util/
 
 ALL=encodings.so hashes.so net.so pposix.so signal.so table.so \
-    ringbuffer.so time.so poll.so compat.so strbitop.so
+    ringbuffer.so time.so poll.so compat.so strbitop.so \
+    struct.so
 
 ifdef RANDOM
 ALL+=crand.so
@@ -24,6 +25,7 @@
 clean:
 	rm -f $(ALL) $(patsubst %.so,%.o,$(ALL))
 
+encodings.o: CFLAGS+=$(IDNA_FLAGS)
 encodings.so: LDLIBS+=$(IDNA_LIBS)
 
 hashes.so: LDLIBS+=$(OPENSSL_LIBS)
--- a/util-src/encodings.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/encodings.c	Mon Dec 12 07:07:13 2022 +0100
@@ -24,6 +24,9 @@
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
 #endif
+#if (LUA_VERSION_NUM < 504)
+#define luaL_pushfail lua_pushnil
+#endif
 
 /***************** BASE64 *****************/
 
@@ -216,7 +219,7 @@
  * Check that a string is valid UTF-8
  * Returns NULL if not
  */
-const char *check_utf8(lua_State *L, int idx, size_t *l) {
+static const char *check_utf8(lua_State *L, int idx, size_t *l) {
 	size_t pos, len;
 	const char *s = luaL_checklstring(L, idx, &len);
 	pos = 0;
@@ -247,7 +250,7 @@
 	size_t len;
 
 	if(!check_utf8(L, 1, &len)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushliteral(L, "invalid utf8");
 		return 2;
 	}
@@ -268,41 +271,47 @@
 #include <unicode/usprep.h>
 #include <unicode/ustring.h>
 #include <unicode/utrace.h>
+#include <unicode/uspoof.h>
+#include <unicode/uidna.h>
 
 static int icu_stringprep_prep(lua_State *L, const UStringPrepProfile *profile) {
 	size_t input_len;
 	int32_t unprepped_len, prepped_len, output_len;
 	const char *input;
 	char output[1024];
+	int flags = USPREP_ALLOW_UNASSIGNED;
 
 	UChar unprepped[1024]; /* Temporary unicode buffer (1024 characters) */
 	UChar prepped[1024];
 
 	UErrorCode err = U_ZERO_ERROR;
 
-	if(!lua_isstring(L, 1)) {
-		lua_pushnil(L);
+	input = luaL_checklstring(L, 1, &input_len);
+
+	if(input_len >= 1024) {
+		luaL_pushfail(L);
 		return 1;
 	}
 
-	input = lua_tolstring(L, 1, &input_len);
-
-	if(input_len >= 1024) {
-		lua_pushnil(L);
-		return 1;
+	/* strict */
+	if(!lua_isnoneornil(L, 2)) {
+		luaL_checktype(L, 2, LUA_TBOOLEAN);
+		if(lua_toboolean(L, 2)) {
+			flags = 0;
+		}
 	}
 
 	u_strFromUTF8(unprepped, 1024, &unprepped_len, input, input_len, &err);
 
 	if(U_FAILURE(err)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1;
 	}
 
-	prepped_len = usprep_prepare(profile, unprepped, unprepped_len, prepped, 1024, USPREP_ALLOW_UNASSIGNED, NULL, &err);
+	prepped_len = usprep_prepare(profile, unprepped, unprepped_len, prepped, 1024, flags, NULL, &err);
 
 	if(U_FAILURE(err)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1;
 	} else {
 		u_strToUTF8(output, 1024, &output_len, prepped, prepped_len, &err);
@@ -310,29 +319,62 @@
 		if(U_SUCCESS(err) && output_len < 1024) {
 			lua_pushlstring(L, output, output_len);
 		} else {
-			lua_pushnil(L);
+			luaL_pushfail(L);
 		}
 
 		return 1;
 	}
 }
 
-UStringPrepProfile *icu_nameprep;
-UStringPrepProfile *icu_nodeprep;
-UStringPrepProfile *icu_resourceprep;
-UStringPrepProfile *icu_saslprep;
+static UStringPrepProfile *icu_nameprep;
+static UStringPrepProfile *icu_nodeprep;
+static UStringPrepProfile *icu_resourceprep;
+static UStringPrepProfile *icu_saslprep;
+static USpoofChecker *icu_spoofcheck;
+static UIDNA *icu_idna2008;
+
+#if (U_ICU_VERSION_MAJOR_NUM < 58)
+/* COMPAT */
+#define USPOOF_CONFUSABLE (USPOOF_SINGLE_SCRIPT_CONFUSABLE | USPOOF_MIXED_SCRIPT_CONFUSABLE | USPOOF_WHOLE_SCRIPT_CONFUSABLE)
+#endif
 
 /* initialize global ICU stringprep profiles */
-void init_icu() {
+static void init_icu(void) {
 	UErrorCode err = U_ZERO_ERROR;
 	utrace_setLevel(UTRACE_VERBOSE);
 	icu_nameprep = usprep_openByType(USPREP_RFC3491_NAMEPREP, &err);
 	icu_nodeprep = usprep_openByType(USPREP_RFC3920_NODEPREP, &err);
 	icu_resourceprep = usprep_openByType(USPREP_RFC3920_RESOURCEPREP, &err);
 	icu_saslprep = usprep_openByType(USPREP_RFC4013_SASLPREP, &err);
+	icu_spoofcheck = uspoof_open(&err);
+	uspoof_setChecks(icu_spoofcheck, USPOOF_CONFUSABLE, &err);
+	int options = UIDNA_DEFAULT;
+#if 0
+	/* COMPAT with future Unicode versions */
+	options |= UIDNA_ALLOW_UNASSIGNED;
+#endif
+#if 1
+	/* Forbid eg labels starting with _ */
+	options |= UIDNA_USE_STD3_RULES;
+#endif
+#if 0
+	/* TODO determine if we need this */
+	options |= UIDNA_CHECK_BIDI;
+#endif
+#if 0
+	/* UTS46 makes it sound like these are the responsibility of registrars */
+	options |= UIDNA_CHECK_CONTEXTJ;
+	options |= UIDNA_CHECK_CONTEXTO;
+#endif
+#if 0
+	/* This disables COMPAT with IDNA 2003 */
+	options |= UIDNA_NONTRANSITIONAL_TO_ASCII;
+	options |= UIDNA_NONTRANSITIONAL_TO_UNICODE;
+#endif
+	icu_idna2008 = uidna_openUTS46(options, &err);
 
 	if(U_FAILURE(err)) {
-		fprintf(stderr, "[c] util.encodings: error: %s\n", u_errorName((UErrorCode)err));
+		fprintf(stderr, "[c] util.encodings: error: %s\n", u_errorName(err));
 	}
 }
 
@@ -362,27 +404,31 @@
 	const char *s;
 	char string[1024];
 	int ret;
-
-	if(!lua_isstring(L, 1)) {
-		lua_pushnil(L);
-		return 1;
-	}
+	Stringprep_profile_flags flags = 0;
 
 	s = check_utf8(L, 1, &len);
 
+	/* strict */
+	if(!lua_isnoneornil(L, 2)) {
+		luaL_checktype(L, 2, LUA_TBOOLEAN);
+		if(lua_toboolean(L, 2)) {
+			flags = STRINGPREP_NO_UNASSIGNED;
+		}
+	}
+
 	if(s == NULL || len >= 1024 || len != strlen(s)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1; /* TODO return error message */
 	}
 
 	strcpy(string, s);
-	ret = stringprep(string, 1024, (Stringprep_profile_flags)0, profile);
+	ret = stringprep(string, 1024, flags, profile);
 
 	if(ret == STRINGPREP_OK) {
 		lua_pushstring(L, string);
 		return 1;
 	} else {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1; /* TODO return error message */
 	}
 }
@@ -421,14 +467,15 @@
 	u_strFromUTF8(ustr, 1024, &ulen, s, len, &err);
 
 	if(U_FAILURE(err)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1;
 	}
 
-	dest_len = uidna_IDNToASCII(ustr, ulen, dest, 1024, UIDNA_USE_STD3_RULES, NULL, &err);
+	UIDNAInfo info = UIDNA_INFO_INITIALIZER;
+	dest_len = uidna_nameToASCII(icu_idna2008, ustr, ulen, dest, 256, &info, &err);
 
-	if(U_FAILURE(err)) {
-		lua_pushnil(L);
+	if(U_FAILURE(err) || info.errors) {
+		luaL_pushfail(L);
 		return 1;
 	} else {
 		u_strToUTF8(output, 1024, &output_len, dest, dest_len, &err);
@@ -436,7 +483,7 @@
 		if(U_SUCCESS(err) && output_len < 1024) {
 			lua_pushlstring(L, output, output_len);
 		} else {
-			lua_pushnil(L);
+			luaL_pushfail(L);
 		}
 
 		return 1;
@@ -455,14 +502,15 @@
 	u_strFromUTF8(ustr, 1024, &ulen, s, len, &err);
 
 	if(U_FAILURE(err)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1;
 	}
 
-	dest_len = uidna_IDNToUnicode(ustr, ulen, dest, 1024, UIDNA_USE_STD3_RULES, NULL, &err);
+	UIDNAInfo info = UIDNA_INFO_INITIALIZER;
+	dest_len = uidna_nameToUnicode(icu_idna2008, ustr, ulen, dest, 1024, &info, &err);
 
-	if(U_FAILURE(err)) {
-		lua_pushnil(L);
+	if(U_FAILURE(err) || info.errors) {
+		luaL_pushfail(L);
 		return 1;
 	} else {
 		u_strToUTF8(output, 1024, &output_len, dest, dest_len, &err);
@@ -470,13 +518,47 @@
 		if(U_SUCCESS(err) && output_len < 1024) {
 			lua_pushlstring(L, output, output_len);
 		} else {
-			lua_pushnil(L);
+			luaL_pushfail(L);
 		}
 
 		return 1;
 	}
 }
 
+static int Lskeleton(lua_State *L) {
+	size_t len;
+	int32_t ulen, dest_len, output_len;
+	const char *s = luaL_checklstring(L, 1, &len);
+	UErrorCode err = U_ZERO_ERROR;
+	UChar ustr[1024];
+	UChar dest[1024];
+	char output[1024];
+
+	u_strFromUTF8(ustr, 1024, &ulen, s, len, &err);
+
+	if(U_FAILURE(err)) {
+		luaL_pushfail(L);
+		return 1;
+	}
+
+	dest_len = uspoof_getSkeleton(icu_spoofcheck, 0, ustr, ulen, dest, 1024, &err);
+
+	if(U_FAILURE(err)) {
+		luaL_pushfail(L);
+		return 1;
+	}
+
+	u_strToUTF8(output, 1024, &output_len, dest, dest_len, &err);
+
+	if(U_SUCCESS(err)) {
+		lua_pushlstring(L, output, output_len);
+		return 1;
+	}
+
+	luaL_pushfail(L);
+	return 1;
+}
+
 #else /* USE_STRINGPREP_ICU */
 /****************** libidn ********************/
 
@@ -490,7 +572,7 @@
 	int ret;
 
 	if(s == NULL || len != strlen(s)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1; /* TODO return error message */
 	}
 
@@ -501,7 +583,7 @@
 		idn_free(output);
 		return 1;
 	} else {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		idn_free(output);
 		return 1; /* TODO return error message */
 	}
@@ -518,7 +600,7 @@
 		idn_free(output);
 		return 1;
 	} else {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		idn_free(output);
 		return 1; /* TODO return error message */
 	}
@@ -558,6 +640,13 @@
 	luaL_setfuncs(L, Reg_utf8, 0);
 	lua_setfield(L, -2, "utf8");
 
+#ifdef USE_STRINGPREP_ICU
+	lua_newtable(L);
+	lua_pushcfunction(L, Lskeleton);
+	lua_setfield(L, -2, "skeleton");
+	lua_setfield(L, -2, "confusable");
+#endif
+
 	lua_pushliteral(L, "-3.14");
 	lua_setfield(L, -2, "version");
 	return 1;
--- a/util-src/hashes.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/hashes.c	Mon Dec 12 07:07:13 2022 +0100
@@ -27,6 +27,7 @@
 #include <openssl/sha.h>
 #include <openssl/md5.h>
 #include <openssl/hmac.h>
+#include <openssl/evp.h>
 
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
@@ -35,8 +36,8 @@
 #define HMAC_IPAD 0x36363636
 #define HMAC_OPAD 0x5c5c5c5c
 
-const char *hex_tab = "0123456789abcdef";
-void toHex(const unsigned char *in, int length, unsigned char *out) {
+static const char *hex_tab = "0123456789abcdef";
+static void toHex(const unsigned char *in, int length, unsigned char *out) {
 	int i;
 
 	for(i = 0; i < length; i++) {
@@ -76,44 +77,6 @@
 	void *ctx, *ctxo;
 };
 
-static void hmac(struct hash_desc *desc, const char *key, size_t key_len,
-                 const char *msg, size_t msg_len, unsigned char *result) {
-	union xory {
-		unsigned char bytes[64];
-		uint32_t quadbytes[16];
-	};
-
-	int i;
-	unsigned char hashedKey[64]; /* Maximum used digest length */
-	union xory k_ipad, k_opad;
-
-	if(key_len > 64) {
-		desc->Init(desc->ctx);
-		desc->Update(desc->ctx, key, key_len);
-		desc->Final(hashedKey, desc->ctx);
-		key = (const char *)hashedKey;
-		key_len = desc->digestLength;
-	}
-
-	memcpy(k_ipad.bytes, key, key_len);
-	memset(k_ipad.bytes + key_len, 0, 64 - key_len);
-	memcpy(k_opad.bytes, k_ipad.bytes, 64);
-
-	for(i = 0; i < 16; i++) {
-		k_ipad.quadbytes[i] ^= HMAC_IPAD;
-		k_opad.quadbytes[i] ^= HMAC_OPAD;
-	}
-
-	desc->Init(desc->ctx);
-	desc->Update(desc->ctx, k_ipad.bytes, 64);
-	desc->Init(desc->ctxo);
-	desc->Update(desc->ctxo, k_opad.bytes, 64);
-	desc->Update(desc->ctx, msg, msg_len);
-	desc->Final(result, desc->ctx);
-	desc->Update(desc->ctxo, result, desc->digestLength);
-	desc->Final(result, desc->ctxo);
-}
-
 #define MAKE_HMAC_FUNCTION(myFunc, evp, size, type) \
 static int myFunc(lua_State *L) { \
 	unsigned char hash[size], result[2*size]; \
@@ -137,56 +100,37 @@
 MAKE_HMAC_FUNCTION(Lhmac_sha512, EVP_sha512, SHA512_DIGEST_LENGTH, SHA512_CTX)
 MAKE_HMAC_FUNCTION(Lhmac_md5, EVP_md5, MD5_DIGEST_LENGTH, MD5_CTX)
 
-static int LscramHi(lua_State *L) {
-	union xory {
-		unsigned char bytes[SHA_DIGEST_LENGTH];
-		uint32_t quadbytes[SHA_DIGEST_LENGTH / 4];
-	};
-	int i;
-	SHA_CTX ctx, ctxo;
-	unsigned char Ust[SHA_DIGEST_LENGTH];
-	union xory Und;
-	union xory res;
-	size_t str_len, salt_len;
-	struct hash_desc desc;
-	const char *str = luaL_checklstring(L, 1, &str_len);
-	const char *salt = luaL_checklstring(L, 2, &salt_len);
-	char *salt2;
+static int Lpbkdf2_sha1(lua_State *L) {
+	unsigned char out[SHA_DIGEST_LENGTH];
+
+	size_t pass_len, salt_len;
+	const char *pass = luaL_checklstring(L, 1, &pass_len);
+	const unsigned char *salt = (unsigned char *)luaL_checklstring(L, 2, &salt_len);
 	const int iter = luaL_checkinteger(L, 3);
 
-	desc.Init = (int (*)(void *))SHA1_Init;
-	desc.Update = (int (*)(void *, const void *, size_t))SHA1_Update;
-	desc.Final = (int (*)(unsigned char *, void *))SHA1_Final;
-	desc.digestLength = SHA_DIGEST_LENGTH;
-	desc.ctx = &ctx;
-	desc.ctxo = &ctxo;
-
-	salt2 = malloc(salt_len + 4);
-
-	if(salt2 == NULL) {
-		return luaL_error(L, "Out of memory in scramHi");
+	if(PKCS5_PBKDF2_HMAC(pass, pass_len, salt, salt_len, iter, EVP_sha1(), SHA_DIGEST_LENGTH, out) == 0) {
+		return luaL_error(L, "PKCS5_PBKDF2_HMAC() failed");
 	}
 
-	memcpy(salt2, salt, salt_len);
-	memcpy(salt2 + salt_len, "\0\0\0\1", 4);
-	hmac(&desc, str, str_len, salt2, salt_len + 4, Ust);
-	free(salt2);
+	lua_pushlstring(L, (char *)out, SHA_DIGEST_LENGTH);
 
-	memcpy(res.bytes, Ust, sizeof(res));
+	return 1;
+}
+
 
-	for(i = 1; i < iter; i++) {
-		int j;
-		hmac(&desc, str, str_len, (char *)Ust, sizeof(Ust), Und.bytes);
+static int Lpbkdf2_sha256(lua_State *L) {
+	unsigned char out[SHA256_DIGEST_LENGTH];
 
-		for(j = 0; j < SHA_DIGEST_LENGTH / 4; j++) {
-			res.quadbytes[j] ^= Und.quadbytes[j];
-		}
+	size_t pass_len, salt_len;
+	const char *pass = luaL_checklstring(L, 1, &pass_len);
+	const unsigned char *salt = (unsigned char *)luaL_checklstring(L, 2, &salt_len);
+	const int iter = luaL_checkinteger(L, 3);
 
-		memcpy(Ust, Und.bytes, sizeof(Ust));
+	if(PKCS5_PBKDF2_HMAC(pass, pass_len, salt, salt_len, iter, EVP_sha256(), SHA256_DIGEST_LENGTH, out) == 0) {
+		return luaL_error(L, "PKCS5_PBKDF2_HMAC() failed");
 	}
 
-	lua_pushlstring(L, (char *)res.bytes, SHA_DIGEST_LENGTH);
-
+	lua_pushlstring(L, (char *)out, SHA256_DIGEST_LENGTH);
 	return 1;
 }
 
@@ -213,7 +157,9 @@
 	{ "hmac_sha256",	Lhmac_sha256	},
 	{ "hmac_sha512",	Lhmac_sha512	},
 	{ "hmac_md5",		Lhmac_md5	},
-	{ "scram_Hi_sha1",	LscramHi	},
+	{ "scram_Hi_sha1",	Lpbkdf2_sha1	}, /* COMPAT */
+	{ "pbkdf2_hmac_sha1",	Lpbkdf2_sha1	},
+	{ "pbkdf2_hmac_sha256",	Lpbkdf2_sha256	},
 	{ "equals",             Lhash_equals    },
 	{ NULL,			NULL		}
 };
@@ -223,8 +169,12 @@
 	luaL_checkversion(L);
 #endif
 	lua_newtable(L);
-	luaL_setfuncs(L, Reg, 0);;
+	luaL_setfuncs(L, Reg, 0);
 	lua_pushliteral(L, "-3.14");
 	lua_setfield(L, -2, "version");
+#ifdef OPENSSL_VERSION
+	lua_pushstring(L, OpenSSL_version(OPENSSL_VERSION));
+	lua_setfield(L, -2, "_LIBCRYPTO_VERSION");
+#endif
 	return 1;
 }
--- a/util-src/makefile	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/makefile	Mon Dec 12 07:07:13 2022 +0100
@@ -6,7 +6,8 @@
 TARGET?=../util/
 
 ALL=encodings.so hashes.so net.so pposix.so signal.so table.so \
-    ringbuffer.so time.so poll.so compat.so strbitop.so
+    ringbuffer.so time.so poll.so compat.so strbitop.so \
+    struct.so
 
 .ifdef $(RANDOM)
 ALL+=crand.so
@@ -23,6 +24,8 @@
 clean:
 	rm -f $(ALL) $(patsubst %.so,%.o,$(ALL))
 
+encodings.o: encodings.c
+	$(CC) $(CFLAGS) $(IDNA_FLAGS) -c -o $@ $<
 encodings.so: encodings.o
 	$(LD) $(LDFLAGS) -o $@ $< $(LDLIBS) $(IDNA_LIBS)
 
--- a/util-src/net.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/net.c	Mon Dec 12 07:07:13 2022 +0100
@@ -33,10 +33,13 @@
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
 #endif
+#if (LUA_VERSION_NUM < 504)
+#define luaL_pushfail lua_pushnil
+#endif
 
 /* Enumerate all locally configured IP addresses */
 
-const char *const type_strings[] = {
+static const char *const type_strings[] = {
 	"both",
 	"ipv4",
 	"ipv6",
@@ -46,8 +49,8 @@
 static int lc_local_addresses(lua_State *L) {
 #ifndef _WIN32
 	/* Link-local IPv4 addresses; see RFC 3927 and RFC 5735 */
-	const long ip4_linklocal = htonl(0xa9fe0000); /* 169.254.0.0 */
-	const long ip4_mask      = htonl(0xffff0000);
+	const uint32_t ip4_linklocal = htonl(0xa9fe0000); /* 169.254.0.0 */
+	const uint32_t ip4_mask      = htonl(0xffff0000);
 	struct ifaddrs *addr = NULL, *a;
 #endif
 	int n = 1;
@@ -59,7 +62,7 @@
 #ifndef _WIN32
 
 	if(getifaddrs(&addr) < 0) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushfstring(L, "getifaddrs failed (%d): %s", errno,
 		                strerror(errno));
 		return 2;
@@ -141,14 +144,14 @@
 
 		case -1:
 			errno_ = errno;
-			lua_pushnil(L);
+			luaL_pushfail(L);
 			lua_pushstring(L, strerror(errno_));
 			lua_pushinteger(L, errno_);
 			return 3;
 
 		default:
 		case 0:
-			lua_pushnil(L);
+			luaL_pushfail(L);
 			lua_pushstring(L, strerror(EINVAL));
 			lua_pushinteger(L, EINVAL);
 			return 3;
@@ -170,7 +173,7 @@
 		family = AF_INET;
 	}
 	else {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(EAFNOSUPPORT));
 		lua_pushinteger(L, EAFNOSUPPORT);
 		return 3;
@@ -179,7 +182,7 @@
 	if(!inet_ntop(family, ipaddr, buf, INET6_ADDRSTRLEN))
 	{
 		errno_ = errno;
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(errno_));
 		lua_pushinteger(L, errno_);
 		return 3;
--- a/util-src/poll.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/poll.c	Mon Dec 12 07:07:13 2022 +0100
@@ -1,7 +1,7 @@
 
 /*
  * Lua polling library
- * Copyright (C) 2017-2018 Kim Alvefur
+ * Copyright (C) 2017-2022 Kim Alvefur
  *
  * This project is MIT licensed. Please see the
  * COPYING file in the source package for more information.
@@ -12,8 +12,15 @@
 #include <string.h>
 #include <errno.h>
 
-#ifdef __linux__
+#if defined(__linux__)
 #define USE_EPOLL
+#define POLL_BACKEND "epoll"
+#elif defined(__unix__)
+#define USE_POLL
+#define POLL_BACKEND "poll"
+#else
+#define USE_SELECT
+#define POLL_BACKEND "select"
 #endif
 
 #ifdef USE_EPOLL
@@ -21,22 +28,28 @@
 #ifndef MAX_EVENTS
 #define MAX_EVENTS 64
 #endif
-#else
+#endif
+#ifdef USE_POLL
+#include <poll.h>
+#ifndef MAX_EVENTS
+#define MAX_EVENTS 10000
+#endif
+#endif
+#ifdef USE_SELECT
 #include <sys/select.h>
 #endif
 
 #include <lualib.h>
 #include <lauxlib.h>
 
-#ifdef USE_EPOLL
-#define STATE_MT "util.poll<epoll>"
-#else
-#define STATE_MT "util.poll<select>"
-#endif
+#define STATE_MT "util.poll<" POLL_BACKEND ">"
 
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setmetatable(L, tname) luaL_getmetatable(L, tname); lua_setmetatable(L, -2)
 #endif
+#if (LUA_VERSION_NUM < 504)
+#define luaL_pushfail lua_pushnil
+#endif
 
 /*
  * Structure to keep state for each type of API
@@ -46,7 +59,12 @@
 #ifdef USE_EPOLL
 	int epoll_fd;
 	struct epoll_event events[MAX_EVENTS];
-#else
+#endif
+#ifdef USE_POLL
+	nfds_t count;
+	struct pollfd events[MAX_EVENTS];
+#endif
+#ifdef USE_SELECT
 	fd_set wantread;
 	fd_set wantwrite;
 	fd_set readable;
@@ -59,7 +77,7 @@
 /*
  * Add an FD to be watched
  */
-int Ladd(lua_State *L) {
+static int Ladd(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 	int fd = luaL_checkinteger(L, 2);
 
@@ -67,7 +85,7 @@
 	int wantwrite = lua_toboolean(L, 4);
 
 	if(fd < 0) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(EBADF));
 		lua_pushinteger(L, EBADF);
 		return 3;
@@ -84,7 +102,7 @@
 
 	if(ret < 0) {
 		ret = errno;
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(ret));
 		lua_pushinteger(L, ret);
 		return 3;
@@ -93,17 +111,44 @@
 	lua_pushboolean(L, 1);
 	return 1;
 
-#else
+#endif
+#ifdef USE_POLL
+
+	for(nfds_t i = 0; i < state->count; i++) {
+		if(state->events[i].fd == fd) {
+			luaL_pushfail(L);
+			lua_pushstring(L, strerror(EEXIST));
+			lua_pushinteger(L, EEXIST);
+			return 3;
+		}
+	}
+
+	if(state->count >= MAX_EVENTS) {
+		luaL_pushfail(L);
+		lua_pushstring(L, strerror(EMFILE));
+		lua_pushinteger(L, EMFILE);
+		return 3;
+	}
+
+	state->events[state->count].fd = fd;
+	state->events[state->count].events = (wantread ? POLLIN : 0) | (wantwrite ? POLLOUT : 0);
+	state->events[state->count].revents = 0;
+	state->count++;
+
+	lua_pushboolean(L, 1);
+	return 1;
+#endif
+#ifdef USE_SELECT
 
 	if(fd > FD_SETSIZE) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(EBADF));
 		lua_pushinteger(L, EBADF);
 		return 3;
 	}
 
 	if(FD_ISSET(fd, &state->all)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(EEXIST));
 		lua_pushinteger(L, EEXIST);
 		return 3;
@@ -137,7 +182,7 @@
 /*
  * Set events to watch for, readable and/or writable
  */
-int Lset(lua_State *L) {
+static int Lset(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 	int fd = luaL_checkinteger(L, 2);
 
@@ -160,18 +205,41 @@
 	}
 	else {
 		ret = errno;
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(ret));
 		lua_pushinteger(L, ret);
 		return 3;
 	}
 
-#else
+#endif
+#ifdef USE_POLL
+	int wantread = lua_toboolean(L, 3);
+	int wantwrite = lua_toboolean(L, 4);
+
+	for(nfds_t i = 0; i < state->count; i++) {
+		struct pollfd *event =  &state->events[i];
+
+		if(event->fd == fd) {
+			event->events = (wantread ? POLLIN : 0) | (wantwrite ? POLLOUT : 0);
+			lua_pushboolean(L, 1);
+			return 1;
+		} else if(event->fd == -1) {
+			break;
+		}
+	}
+
+	luaL_pushfail(L);
+	lua_pushstring(L, strerror(ENOENT));
+	lua_pushinteger(L, ENOENT);
+	return 3;
+#endif
+#ifdef USE_SELECT
 
 	if(!FD_ISSET(fd, &state->all)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(ENOENT));
 		lua_pushinteger(L, ENOENT);
+		return 3;
 	}
 
 	if(!lua_isnoneornil(L, 3)) {
@@ -200,7 +268,7 @@
 /*
  * Remove FDs
  */
-int Ldel(lua_State *L) {
+static int Ldel(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 	int fd = luaL_checkinteger(L, 2);
 
@@ -217,18 +285,54 @@
 	}
 	else {
 		ret = errno;
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(ret));
 		lua_pushinteger(L, ret);
 		return 3;
 	}
 
-#else
+#endif
+#ifdef USE_POLL
+
+	if(state->count == 0) {
+		luaL_pushfail(L);
+		lua_pushstring(L, strerror(ENOENT));
+		lua_pushinteger(L, ENOENT);
+		return 3;
+	}
+
+	/*
+	 * Move the last item on top of the removed one
+	 */
+	struct pollfd *last = &state->events[state->count - 1];
+
+	for(nfds_t i = 0; i < state->count; i++) {
+		struct pollfd *event = &state->events[i];
+
+		if(event->fd == fd) {
+			event->fd = last->fd;
+			event->events = last->events;
+			event->revents = last->revents;
+			last->fd = -1;
+			state->count--;
+
+			lua_pushboolean(L, 1);
+			return 1;
+		}
+	}
+
+	luaL_pushfail(L);
+	lua_pushstring(L, strerror(ENOENT));
+	lua_pushinteger(L, ENOENT);
+	return 3;
+#endif
+#ifdef USE_SELECT
 
 	if(!FD_ISSET(fd, &state->all)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(ENOENT));
 		lua_pushinteger(L, ENOENT);
+		return 3;
 	}
 
 	FD_CLR(fd, &state->wantread);
@@ -247,7 +351,7 @@
 /*
  * Check previously manipulated event state for FDs ready for reading or writing
  */
-int Lpushevent(lua_State *L, struct Lpoll_state *state) {
+static int Lpushevent(lua_State *L, struct Lpoll_state *state) {
 #ifdef USE_EPOLL
 
 	if(state->processed > 0) {
@@ -259,7 +363,24 @@
 		return 3;
 	}
 
-#else
+#endif
+#ifdef USE_POLL
+
+	for(int i = state->processed - 1; i >= 0; i--) {
+		struct pollfd *event = &state->events[i];
+
+		if(event->fd != -1 && event->revents != 0) {
+			lua_pushinteger(L, event->fd);
+			lua_pushboolean(L, event->revents & (POLLIN | POLLHUP | POLLERR));
+			lua_pushboolean(L, event->revents & POLLOUT);
+			event->revents = 0;
+			state->processed = i;
+			return 3;
+		}
+	}
+
+#endif
+#ifdef USE_SELECT
 
 	for(int fd = state->processed + 1; fd < FD_SETSIZE; fd++) {
 		if(FD_ISSET(fd, &state->readable) || FD_ISSET(fd, &state->writable) || FD_ISSET(fd, &state->err)) {
@@ -281,7 +402,7 @@
 /*
  * Wait for event
  */
-int Lwait(lua_State *L) {
+static int Lwait(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 
 	int ret = Lpushevent(L, state);
@@ -295,7 +416,11 @@
 
 #ifdef USE_EPOLL
 	ret = epoll_wait(state->epoll_fd, state->events, MAX_EVENTS, timeout * 1000);
-#else
+#endif
+#ifdef USE_POLL
+	ret = poll(state->events, state->count, timeout * 1000);
+#endif
+#ifdef USE_SELECT
 	/*
 	 * select(2) mutates the fd_sets passed to it so in order to not
 	 * have to recreate it manually every time a copy is made.
@@ -312,18 +437,20 @@
 #endif
 
 	if(ret == 0) {
+		/* Is this an error? */
 		lua_pushnil(L);
 		lua_pushstring(L, "timeout");
 		return 2;
 	}
 	else if(ret < 0 && errno == EINTR) {
+		/* Is this an error? */
 		lua_pushnil(L);
 		lua_pushstring(L, "signal");
 		return 2;
 	}
 	else if(ret < 0) {
 		ret = errno;
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(ret));
 		lua_pushinteger(L, ret);
 		return 3;
@@ -334,7 +461,11 @@
 	 */
 #ifdef USE_EPOLL
 	state->processed = ret;
-#else
+#endif
+#ifdef USE_POLL
+	state->processed = state->count;
+#endif
+#ifdef USE_SELECT
 	state->processed = -1;
 #endif
 	return Lpushevent(L, state);
@@ -344,7 +475,7 @@
 /*
  * Return Epoll FD
  */
-int Lgetfd(lua_State *L) {
+static int Lgetfd(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 	lua_pushinteger(L, state->epoll_fd);
 	return 1;
@@ -353,7 +484,7 @@
 /*
  * Close epoll FD
  */
-int Lgc(lua_State *L) {
+static int Lgc(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 
 	if(state->epoll_fd == -1) {
@@ -375,7 +506,7 @@
 /*
  * String representation
  */
-int Ltos(lua_State *L) {
+static int Ltos(lua_State *L) {
 	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
 	lua_pushfstring(L, "%s: %p", STATE_MT, state);
 	return 1;
@@ -384,7 +515,7 @@
 /*
  * Create a new context
  */
-int Lnew(lua_State *L) {
+static int Lnew(lua_State *L) {
 	/* Allocate state */
 	Lpoll_state *state = lua_newuserdata(L, sizeof(Lpoll_state));
 	luaL_setmetatable(L, STATE_MT);
@@ -397,14 +528,26 @@
 	int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
 
 	if(epoll_fd <= 0) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(errno));
 		lua_pushinteger(L, errno);
 		return 3;
 	}
 
 	state->epoll_fd = epoll_fd;
-#else
+#endif
+#ifdef USE_POLL
+	state->processed = -1;
+	state->count = 0;
+
+	for(nfds_t i = 0; i < MAX_EVENTS; i++) {
+		state->events[i].fd = -1;
+		state->events[i].events = 0;
+		state->events[i].revents = 0;
+	}
+
+#endif
+#ifdef USE_SELECT
 	FD_ZERO(&state->wantread);
 	FD_ZERO(&state->wantwrite);
 	FD_ZERO(&state->readable);
@@ -466,8 +609,12 @@
 		lua_setfield(L, -2, #named_error);
 
 		push_errno(EEXIST);
+		push_errno(EMFILE);
 		push_errno(ENOENT);
 
+		lua_pushliteral(L, POLL_BACKEND);
+		lua_setfield(L, -2, "api");
+
 	}
 	return 1;
 }
--- a/util-src/pposix.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/pposix.c	Mon Dec 12 07:07:13 2022 +0100
@@ -25,14 +25,18 @@
 #define _DEFAULT_SOURCE
 #endif
 #endif
+
 #if defined(__APPLE__)
 #ifndef _DARWIN_C_SOURCE
 #define _DARWIN_C_SOURCE
 #endif
 #endif
+
+#if ! defined(__FreeBSD__)
 #ifndef _POSIX_C_SOURCE
 #define _POSIX_C_SOURCE 200809L
 #endif
+#endif
 
 #include <stdlib.h>
 #include <math.h>
@@ -57,6 +61,12 @@
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
 #endif
+#if (LUA_VERSION_NUM < 503)
+#define lua_isinteger(L, n) lua_isnumber(L, n)
+#endif
+#if (LUA_VERSION_NUM < 504)
+#define luaL_pushfail lua_pushnil
+#endif
 
 #include <fcntl.h>
 #if defined(__linux__)
@@ -102,7 +112,7 @@
 	} else if(pid != 0) {
 		/* We are the parent process */
 		lua_pushboolean(L, 1);
-		lua_pushnumber(L, pid);
+		lua_pushinteger(L, pid);
 		return 2;
 	}
 
@@ -133,7 +143,7 @@
 
 /* Syslog support */
 
-const char *const facility_strings[] = {
+static const char *const facility_strings[] = {
 	"auth",
 #if !(defined(sun) || defined(__sun))
 	"authpriv",
@@ -159,7 +169,7 @@
 	"uucp",
 	NULL
 };
-int facility_constants[] =	{
+static int facility_constants[] =	{
 	LOG_AUTH,
 #if !(defined(sun) || defined(__sun))
 	LOG_AUTHPRIV,
@@ -195,9 +205,9 @@
        constant.
    " -- syslog manpage
 */
-char *syslog_ident = NULL;
+static char *syslog_ident = NULL;
 
-int lc_syslog_open(lua_State *L) {
+static int lc_syslog_open(lua_State *L) {
 	int facility = luaL_checkoption(L, 2, "daemon", facility_strings);
 	facility = facility_constants[facility];
 
@@ -213,7 +223,7 @@
 	return 0;
 }
 
-const char *const level_strings[] = {
+static const char *const level_strings[] = {
 	"debug",
 	"info",
 	"notice",
@@ -221,7 +231,7 @@
 	"error",
 	NULL
 };
-int level_constants[] = 	{
+static int level_constants[] = 	{
 	LOG_DEBUG,
 	LOG_INFO,
 	LOG_NOTICE,
@@ -229,7 +239,7 @@
 	LOG_CRIT,
 	-1
 };
-int lc_syslog_log(lua_State *L) {
+static int lc_syslog_log(lua_State *L) {
 	int level = level_constants[luaL_checkoption(L, 1, "notice", level_strings)];
 
 	if(lua_gettop(L) == 3) {
@@ -241,7 +251,7 @@
 	return 0;
 }
 
-int lc_syslog_close(lua_State *L) {
+static int lc_syslog_close(lua_State *L) {
 	(void)L;
 	closelog();
 
@@ -253,7 +263,7 @@
 	return 0;
 }
 
-int lc_syslog_setmask(lua_State *L) {
+static int lc_syslog_setmask(lua_State *L) {
 	int level_idx = luaL_checkoption(L, 1, "notice", level_strings);
 	int mask = 0;
 
@@ -267,31 +277,31 @@
 
 /* getpid */
 
-int lc_getpid(lua_State *L) {
+static int lc_getpid(lua_State *L) {
 	lua_pushinteger(L, getpid());
 	return 1;
 }
 
 /* UID/GID functions */
 
-int lc_getuid(lua_State *L) {
+static int lc_getuid(lua_State *L) {
 	lua_pushinteger(L, getuid());
 	return 1;
 }
 
-int lc_getgid(lua_State *L) {
+static int lc_getgid(lua_State *L) {
 	lua_pushinteger(L, getgid());
 	return 1;
 }
 
-int lc_setuid(lua_State *L) {
+static int lc_setuid(lua_State *L) {
 	int uid = -1;
 
 	if(lua_gettop(L) < 1) {
 		return 0;
 	}
 
-	if(!lua_isnumber(L, 1) && lua_tostring(L, 1)) {
+	if(!lua_isinteger(L, 1) && lua_tostring(L, 1)) {
 		/* Passed UID is actually a string, so look up the UID */
 		struct passwd *p;
 		p = getpwnam(lua_tostring(L, 1));
@@ -304,7 +314,7 @@
 
 		uid = p->pw_uid;
 	} else {
-		uid = lua_tonumber(L, 1);
+		uid = lua_tointeger(L, 1);
 	}
 
 	if(uid > -1) {
@@ -342,14 +352,14 @@
 	return 2;
 }
 
-int lc_setgid(lua_State *L) {
+static int lc_setgid(lua_State *L) {
 	int gid = -1;
 
 	if(lua_gettop(L) < 1) {
 		return 0;
 	}
 
-	if(!lua_isnumber(L, 1) && lua_tostring(L, 1)) {
+	if(!lua_isinteger(L, 1) && lua_tostring(L, 1)) {
 		/* Passed GID is actually a string, so look up the GID */
 		struct group *g;
 		g = getgrnam(lua_tostring(L, 1));
@@ -362,7 +372,7 @@
 
 		gid = g->gr_gid;
 	} else {
-		gid = lua_tonumber(L, 1);
+		gid = lua_tointeger(L, 1);
 	}
 
 	if(gid > -1) {
@@ -400,13 +410,13 @@
 	return 2;
 }
 
-int lc_initgroups(lua_State *L) {
+static int lc_initgroups(lua_State *L) {
 	int ret;
 	gid_t gid;
 	struct passwd *p;
 
 	if(!lua_isstring(L, 1)) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, "invalid-username");
 		return 2;
 	}
@@ -414,7 +424,7 @@
 	p = getpwnam(lua_tostring(L, 1));
 
 	if(!p) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, "no-such-user");
 		return 2;
 	}
@@ -433,7 +443,7 @@
 			break;
 
 		default:
-			lua_pushnil(L);
+			luaL_pushfail(L);
 			lua_pushstring(L, "invalid-gid");
 			return 2;
 	}
@@ -443,17 +453,17 @@
 	if(ret) {
 		switch(errno) {
 			case ENOMEM:
-				lua_pushnil(L);
+				luaL_pushfail(L);
 				lua_pushstring(L, "no-memory");
 				break;
 
 			case EPERM:
-				lua_pushnil(L);
+				luaL_pushfail(L);
 				lua_pushstring(L, "permission-denied");
 				break;
 
 			default:
-				lua_pushnil(L);
+				luaL_pushfail(L);
 				lua_pushstring(L, "unknown-error");
 		}
 	} else {
@@ -464,7 +474,7 @@
 	return 2;
 }
 
-int lc_umask(lua_State *L) {
+static int lc_umask(lua_State *L) {
 	char old_mode_string[7];
 	mode_t old_mode = umask(strtoul(luaL_checkstring(L, 1), NULL, 8));
 
@@ -475,7 +485,7 @@
 	return 1;
 }
 
-int lc_mkdir(lua_State *L) {
+static int lc_mkdir(lua_State *L) {
 	int ret = mkdir(luaL_checkstring(L, 1), S_IRUSR | S_IWUSR | S_IXUSR
 	                | S_IRGRP | S_IWGRP | S_IXGRP
 	                | S_IROTH | S_IXOTH); /* mode 775 */
@@ -500,7 +510,7 @@
  *	Example usage:
  *	pposix.setrlimit("NOFILE", 1000, 2000)
  */
-int string2resource(const char *s) {
+static int string2resource(const char *s) {
 	if(!strcmp(s, "CORE")) {
 		return RLIMIT_CORE;
 	}
@@ -550,7 +560,7 @@
 	return -1;
 }
 
-rlim_t arg_to_rlimit(lua_State *L, int idx, rlim_t current) {
+static rlim_t arg_to_rlimit(lua_State *L, int idx, rlim_t current) {
 	switch(lua_type(L, idx)) {
 		case LUA_TSTRING:
 
@@ -571,7 +581,7 @@
 	}
 }
 
-int lc_setrlimit(lua_State *L) {
+static int lc_setrlimit(lua_State *L) {
 	struct rlimit lim;
 	int arguments = lua_gettop(L);
 	int rid = -1;
@@ -610,7 +620,7 @@
 	return 1;
 }
 
-int lc_getrlimit(lua_State *L) {
+static int lc_getrlimit(lua_State *L) {
 	int arguments = lua_gettop(L);
 	const char *resource = NULL;
 	int rid = -1;
@@ -643,29 +653,29 @@
 	if(lim.rlim_cur == RLIM_INFINITY) {
 		lua_pushstring(L, "unlimited");
 	} else {
-		lua_pushnumber(L, lim.rlim_cur);
+		lua_pushinteger(L, lim.rlim_cur);
 	}
 
 	if(lim.rlim_max == RLIM_INFINITY) {
 		lua_pushstring(L, "unlimited");
 	} else {
-		lua_pushnumber(L, lim.rlim_max);
+		lua_pushinteger(L, lim.rlim_max);
 	}
 
 	return 3;
 }
 
-int lc_abort(lua_State *L) {
+static int lc_abort(lua_State *L) {
 	(void)L;
 	abort();
 	return 0;
 }
 
-int lc_uname(lua_State *L) {
+static int lc_uname(lua_State *L) {
 	struct utsname uname_info;
 
 	if(uname(&uname_info) != 0) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(errno));
 		return 2;
 	}
@@ -688,14 +698,14 @@
 	return 1;
 }
 
-int lc_setenv(lua_State *L) {
+static int lc_setenv(lua_State *L) {
 	const char *var = luaL_checkstring(L, 1);
 	const char *value;
 
 	/* If the second argument is nil or nothing, unset the var */
 	if(lua_isnoneornil(L, 2)) {
 		if(unsetenv(var) != 0) {
-			lua_pushnil(L);
+			luaL_pushfail(L);
 			lua_pushstring(L, strerror(errno));
 			return 2;
 		}
@@ -707,7 +717,7 @@
 	value = luaL_checkstring(L, 2);
 
 	if(setenv(var, value, 1) != 0) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushstring(L, strerror(errno));
 		return 2;
 	}
@@ -717,27 +727,34 @@
 }
 
 #ifdef WITH_MALLINFO
-int lc_meminfo(lua_State *L) {
+static int lc_meminfo(lua_State *L) {
+#if __GLIBC_PREREQ(2, 33)
+	struct mallinfo2 info = mallinfo2();
+#define MALLINFO_T size_t
+#else
 	struct mallinfo info = mallinfo();
+#define MALLINFO_T unsigned
+#endif
 	lua_createtable(L, 0, 5);
 	/* This is the total size of memory allocated with sbrk by malloc, in bytes. */
-	lua_pushinteger(L, (unsigned)info.arena);
+	lua_pushinteger(L, (MALLINFO_T)info.arena);
 	lua_setfield(L, -2, "allocated");
 	/* This is the total size of memory allocated with mmap, in bytes. */
-	lua_pushinteger(L, (unsigned)info.hblkhd);
+	lua_pushinteger(L, (MALLINFO_T)info.hblkhd);
 	lua_setfield(L, -2, "allocated_mmap");
 	/* This is the total size of memory occupied by chunks handed out by malloc. */
-	lua_pushinteger(L, (unsigned)info.uordblks);
+	lua_pushinteger(L, (MALLINFO_T)info.uordblks);
 	lua_setfield(L, -2, "used");
 	/* This is the total size of memory occupied by free (not in use) chunks. */
-	lua_pushinteger(L, (unsigned)info.fordblks);
+	lua_pushinteger(L, (MALLINFO_T)info.fordblks);
 	lua_setfield(L, -2, "unused");
 	/* This is the size of the top-most releasable chunk that normally borders the
 	   end of the heap (i.e., the high end of the virtual address space's data segment). */
-	lua_pushinteger(L, (unsigned)info.keepcost);
+	lua_pushinteger(L, (MALLINFO_T)info.keepcost);
 	lua_setfield(L, -2, "returnable");
 	return 1;
 }
+#undef MALLINFO_T
 #endif
 
 /*
@@ -745,7 +762,7 @@
  * Attempt to allocate space first
  * Truncate to original size on failure
  */
-int lc_atomic_append(lua_State *L) {
+static int lc_atomic_append(lua_State *L) {
 	int err;
 	size_t len;
 
@@ -769,7 +786,7 @@
 
 			case ENOSPC: /* No space left */
 			default: /* Other issues */
-				lua_pushnil(L);
+				luaL_pushfail(L);
 				lua_pushstring(L, strerror(err));
 				lua_pushinteger(L, err);
 				return 3;
@@ -796,12 +813,19 @@
 		return luaL_error(L, "atomic_append() failed in ftruncate(): %s", strerror(errno));
 	}
 
-	lua_pushnil(L);
+	luaL_pushfail(L);
 	lua_pushstring(L, strerror(err));
 	lua_pushinteger(L, err);
 	return 3;
 }
 
+static int lc_isatty(lua_State *L) {
+	FILE *f = *(FILE **) luaL_checkudata(L, 1, LUA_FILEHANDLE);
+	const int fd = fileno(f);
+	lua_pushboolean(L, isatty(fd));
+	return 1;
+}
+
 /* Register functions */
 
 int luaopen_util_pposix(lua_State *L) {
@@ -843,6 +867,8 @@
 
 		{ "atomic_append", lc_atomic_append },
 
+		{ "isatty", lc_isatty },
+
 		{ NULL, NULL }
 	};
 
--- a/util-src/ringbuffer.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/ringbuffer.c	Mon Dec 12 07:07:13 2022 +0100
@@ -2,11 +2,14 @@
 #include <stdlib.h>
 #include <unistd.h>
 #include <string.h>
-#include <stdio.h>
 
 #include <lua.h>
 #include <lauxlib.h>
 
+#if (LUA_VERSION_NUM < 504)
+#define luaL_pushfail lua_pushnil
+#endif
+
 typedef struct {
 	size_t rpos; /* read position */
 	size_t wpos; /* write position */
@@ -15,23 +18,67 @@
 	char buffer[];
 } ringbuffer;
 
-char readchar(ringbuffer *b) {
-	b->blen--;
-	return b->buffer[(b->rpos++) % b->alen];
+/* Translate absolute idx to a wrapped index within the buffer,
+   based on current read position */
+static int wrap_pos(const ringbuffer *b, const long idx, long *pos) {
+	if(idx > (long)b->blen) {
+		return 0;
+	}
+	if(idx + (long)b->rpos > (long)b->alen) {
+		*pos = idx - (b->alen - b->rpos);
+	} else {
+		*pos = b->rpos + idx;
+	}
+	return 1;
 }
 
-void writechar(ringbuffer *b, char c) {
+static int calc_splice_positions(const ringbuffer *b, long start, long end, long *out_start, long *out_end) {
+	if(start < 0) {
+		start = 1 + start + b->blen;
+	}
+	if(start <= 0) {
+		start = 1;
+	}
+
+	if(end < 0) {
+		end = 1 + end + b->blen;
+	}
+
+	if(end > (long)b->blen) {
+		end = b->blen;
+	}
+	if(start < 1) {
+		start = 1;
+	}
+
+	if(start > end) {
+		return 0;
+	}
+
+	start = start - 1;
+
+	if(!wrap_pos(b, start, out_start)) {
+		return 0;
+	}
+	if(!wrap_pos(b, end, out_end)) {
+		return 0;
+	}
+
+	return 1;
+}
+
+static void writechar(ringbuffer *b, char c) {
 	b->blen++;
 	b->buffer[(b->wpos++) % b->alen] = c;
 }
 
 /* make sure position counters stay within the allocation */
-void modpos(ringbuffer *b) {
+static void modpos(ringbuffer *b) {
 	b->rpos = b->rpos % b->alen;
 	b->wpos = b->wpos % b->alen;
 }
 
-int find(ringbuffer *b, const char *s, size_t l) {
+static int find(ringbuffer *b, const char *s, size_t l) {
 	size_t i, j;
 	int m;
 
@@ -64,7 +111,7 @@
  * Find first position of a substring in buffer
  * (buffer, string) -> number
  */
-int rb_find(lua_State *L) {
+static int rb_find(lua_State *L) {
 	size_t l, m;
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	const char *s = luaL_checklstring(L, 2, &l);
@@ -82,7 +129,7 @@
  * Move read position forward without returning the data
  * (buffer, number) -> boolean
  */
-int rb_discard(lua_State *L) {
+static int rb_discard(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	size_t r = luaL_checkinteger(L, 2);
 
@@ -103,13 +150,13 @@
  * Read bytes from buffer
  * (buffer, number, boolean?) -> string
  */
-int rb_read(lua_State *L) {
+static int rb_read(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	size_t r = luaL_checkinteger(L, 2);
 	int peek = lua_toboolean(L, 3);
 
 	if(r > b->blen) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1;
 	}
 
@@ -135,7 +182,7 @@
  * Read buffer until first occurrence of a substring
  * (buffer, string) -> string
  */
-int rb_readuntil(lua_State *L) {
+static int rb_readuntil(lua_State *L) {
 	size_t l, m;
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	const char *s = luaL_checklstring(L, 2, &l);
@@ -154,14 +201,14 @@
  * Write bytes into the buffer
  * (buffer, string) -> integer
  */
-int rb_write(lua_State *L) {
+static int rb_write(lua_State *L) {
 	size_t l, w = 0;
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	const char *s = luaL_checklstring(L, 2, &l);
 
 	/* Does `l` bytes fit? */
 	if((l + b->blen) > b->alen) {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		return 1;
 	}
 
@@ -177,32 +224,82 @@
 	return 1;
 }
 
-int rb_tostring(lua_State *L) {
+static int rb_tostring(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	lua_pushfstring(L, "ringbuffer: %p %d/%d", b, b->blen, b->alen);
 	return 1;
 }
 
-int rb_length(lua_State *L) {
+static int rb_sub(lua_State *L) {
+	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
+
+	long start = luaL_checkinteger(L, 2);
+	long end = luaL_optinteger(L, 3, -1);
+
+	long wrapped_start, wrapped_end;
+	if(!calc_splice_positions(b, start, end, &wrapped_start, &wrapped_end)) {
+		lua_pushstring(L, "");
+	} else if(wrapped_end <= wrapped_start) {
+		lua_pushlstring(L, &b->buffer[wrapped_start], b->alen - wrapped_start);
+		lua_pushlstring(L, b->buffer, wrapped_end);
+		lua_concat(L, 2);
+	} else {
+		lua_pushlstring(L, &b->buffer[wrapped_start], (wrapped_end - wrapped_start));
+	}
+
+	return 1;
+}
+
+static int rb_byte(lua_State *L) {
+	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
+
+	long start = luaL_optinteger(L, 2, 1);
+	long end = luaL_optinteger(L, 3, start);
+
+	long i;
+
+	long wrapped_start, wrapped_end;
+	if(calc_splice_positions(b, start, end, &wrapped_start, &wrapped_end)) {
+		if(wrapped_end <= wrapped_start) {
+			for(i = wrapped_start; i < (long)b->alen; i++) {
+				lua_pushinteger(L, (unsigned char)b->buffer[i]);
+			}
+			for(i = 0; i < wrapped_end; i++) {
+				lua_pushinteger(L, (unsigned char)b->buffer[i]);
+			}
+			return wrapped_end + (b->alen - wrapped_start);
+		} else {
+			for(i = wrapped_start; i < wrapped_end; i++) {
+				lua_pushinteger(L, (unsigned char)b->buffer[i]);
+			}
+			return wrapped_end - wrapped_start;
+		}
+	}
+
+	return 0;
+}
+
+static int rb_length(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	lua_pushinteger(L, b->blen);
 	return 1;
 }
 
-int rb_size(lua_State *L) {
+static int rb_size(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	lua_pushinteger(L, b->alen);
 	return 1;
 }
 
-int rb_free(lua_State *L) {
+static int rb_free(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	lua_pushinteger(L, b->alen - b->blen);
 	return 1;
 }
 
-int rb_new(lua_State *L) {
-	size_t size = luaL_optinteger(L, 1, sysconf(_SC_PAGESIZE));
+static int rb_new(lua_State *L) {
+	lua_Integer size = luaL_optinteger(L, 1, sysconf(_SC_PAGESIZE));
+	luaL_argcheck(L, size > 0, 1, "positive integer expected");
 	ringbuffer *b = lua_newuserdata(L, sizeof(ringbuffer) + size);
 
 	b->rpos = 0;
@@ -243,6 +340,10 @@
 			lua_setfield(L, -2, "size");
 			lua_pushcfunction(L, rb_length);
 			lua_setfield(L, -2, "length");
+			lua_pushcfunction(L, rb_sub);
+			lua_setfield(L, -2, "sub");
+			lua_pushcfunction(L, rb_byte);
+			lua_setfield(L, -2, "byte");
 			lua_pushcfunction(L, rb_free);
 			lua_setfield(L, -2, "free");
 		}
--- a/util-src/signal.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/signal.c	Mon Dec 12 07:07:13 2022 +0100
@@ -39,6 +39,9 @@
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
 #endif
+#if (LUA_VERSION_NUM < 503)
+#define lua_isinteger(L, n) lua_isnumber(L, n)
+#endif
 
 #ifndef lsig
 
@@ -164,8 +167,8 @@
 static int Hmask = 0;
 static int Hcount = 0;
 
-int signals[MAX_PENDING_SIGNALS];
-int nsig = 0;
+static int signals[MAX_PENDING_SIGNALS];
+static int nsig = 0;
 
 static void sighook(lua_State *L, lua_Debug *ar) {
 	(void)ar;
@@ -176,7 +179,7 @@
 	lua_gettable(L, LUA_REGISTRYINDEX);
 
 	for(int i = 0; i < nsig; i++) {
-		lua_pushnumber(L, signals[i]);
+		lua_pushinteger(L, signals[i]);
 		lua_gettable(L, -2);
 		lua_call(L, 0, 0);
 	};
@@ -223,18 +226,18 @@
 	t = lua_type(L, 1);
 
 	if(t == LUA_TNUMBER) {
-		sig = (int) lua_tonumber(L, 1);
+		sig = (int) lua_tointeger(L, 1);
 	} else if(t == LUA_TSTRING) {
 		lua_pushstring(L, LUA_SIGNAL);
 		lua_gettable(L, LUA_REGISTRYINDEX);
 		lua_pushvalue(L, 1);
 		lua_gettable(L, -2);
 
-		if(!lua_isnumber(L, -1)) {
+		if(!lua_isinteger(L, -1)) {
 			return luaL_error(L, "invalid signal string");
 		}
 
-		sig = (int) lua_tonumber(L, -1);
+		sig = (int) lua_tointeger(L, -1);
 		lua_pop(L, 1); /* get rid of number we pushed */
 	} else {
 		luaL_checknumber(L, 1);    /* will always error, with good error msg */
@@ -245,9 +248,9 @@
 	if(args == 1 || lua_isnil(L, 2)) { /* clear handler */
 		lua_pushstring(L, LUA_SIGNAL);
 		lua_gettable(L, LUA_REGISTRYINDEX);
-		lua_pushnumber(L, sig);
+		lua_pushinteger(L, sig);
 		lua_gettable(L, -2); /* return old handler */
-		lua_pushnumber(L, sig);
+		lua_pushinteger(L, sig);
 		lua_pushnil(L);
 		lua_settable(L, -4);
 		lua_remove(L, -2); /* remove LUA_SIGNAL table */
@@ -258,7 +261,7 @@
 		lua_pushstring(L, LUA_SIGNAL);
 		lua_gettable(L, LUA_REGISTRYINDEX);
 
-		lua_pushnumber(L, sig);
+		lua_pushinteger(L, sig);
 		lua_pushvalue(L, 2);
 		lua_settable(L, -3);
 
@@ -292,15 +295,15 @@
 static int l_raise(lua_State *L) {
 	/* int args = lua_gettop(L); */
 	int t = 0; /* type */
-	lua_Number ret;
+	lua_Integer ret;
 
 	luaL_checkany(L, 1);
 
 	t = lua_type(L, 1);
 
 	if(t == LUA_TNUMBER) {
-		ret = (lua_Number) raise((int) lua_tonumber(L, 1));
-		lua_pushnumber(L, ret);
+		ret = (lua_Integer) raise((int) lua_tointeger(L, 1));
+		lua_pushinteger(L, ret);
 	} else if(t == LUA_TSTRING) {
 		lua_pushstring(L, LUA_SIGNAL);
 		lua_gettable(L, LUA_REGISTRYINDEX);
@@ -311,9 +314,9 @@
 			return luaL_error(L, "invalid signal string");
 		}
 
-		ret = (lua_Number) raise((int) lua_tonumber(L, -1));
+		ret = (lua_Integer) raise((int) lua_tointeger(L, -1));
 		lua_pop(L, 1); /* get rid of number we pushed */
-		lua_pushnumber(L, ret);
+		lua_pushinteger(L, ret);
 	} else {
 		luaL_checknumber(L, 1);    /* will always error, with good error msg */
 	}
@@ -334,7 +337,7 @@
 
 static int l_kill(lua_State *L) {
 	int t; /* type */
-	lua_Number ret; /* return value */
+	lua_Integer ret; /* return value */
 
 	luaL_checknumber(L, 1); /* must be int for pid */
 	luaL_checkany(L, 2); /* check for a second arg */
@@ -342,9 +345,9 @@
 	t = lua_type(L, 2);
 
 	if(t == LUA_TNUMBER) {
-		ret = (lua_Number) kill((int) lua_tonumber(L, 1),
-		                        (int) lua_tonumber(L, 2));
-		lua_pushnumber(L, ret);
+		ret = (lua_Integer) kill((int) lua_tointeger(L, 1),
+		                         (int) lua_tointeger(L, 2));
+		lua_pushinteger(L, ret);
 	} else if(t == LUA_TSTRING) {
 		lua_pushstring(L, LUA_SIGNAL);
 		lua_gettable(L, LUA_REGISTRYINDEX);
@@ -355,10 +358,10 @@
 			return luaL_error(L, "invalid signal string");
 		}
 
-		ret = (lua_Number) kill((int) lua_tonumber(L, 1),
-		                        (int) lua_tonumber(L, -1));
+		ret = (lua_Integer) kill((int) lua_tointeger(L, 1),
+		                         (int) lua_tointeger(L, -1));
 		lua_pop(L, 1); /* get rid of number we pushed */
-		lua_pushnumber(L, ret);
+		lua_pushinteger(L, ret);
 	} else {
 		luaL_checknumber(L, 2);    /* will always error, with good error msg */
 	}
@@ -396,11 +399,11 @@
 	while(lua_signals[i].name != NULL) {
 		/* registry table */
 		lua_pushstring(L, lua_signals[i].name);
-		lua_pushnumber(L, lua_signals[i].sig);
+		lua_pushinteger(L, lua_signals[i].sig);
 		lua_settable(L, -3);
 		/* signal table */
 		lua_pushstring(L, lua_signals[i].name);
-		lua_pushnumber(L, lua_signals[i].sig);
+		lua_pushinteger(L, lua_signals[i].sig);
 		lua_settable(L, -5);
 		i++;
 	}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/struct.c	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,422 @@
+/*
+** {======================================================
+** Library for packing/unpacking structures.
+** $Id: struct.c,v 1.8 2018/05/16 11:00:23 roberto Exp $
+** See Copyright Notice at the end of this file
+** =======================================================
+*/
+/*
+** Valid formats:
+** > - big endian
+** < - little endian
+** ![num] - alignment
+** x - padding
+** b/B - signed/unsigned byte
+** h/H - signed/unsigned short
+** l/L - signed/unsigned long
+** T   - size_t
+** i/In - signed/unsigned integer with size 'n' (default is size of int)
+** cn - sequence of 'n' chars (from/to a string); when packing, n==0 means
+        the whole string; when unpacking, n==0 means use the previous
+        read number as the string length
+** s - zero-terminated string
+** f - float
+** d - double
+** ' ' - ignored
+*/
+
+
+#include <ctype.h>
+#include <limits.h>
+#include <stddef.h>
+#include <string.h>
+
+
+#include "lua.h"
+#include "lauxlib.h"
+
+
+#if (LUA_VERSION_NUM >= 502)
+
+#define luaL_register(L,n,f)	luaL_newlib(L,f)
+
+#endif
+
+
+/* basic integer type */
+#if !defined(STRUCT_INT)
+#define STRUCT_INT	long
+#endif
+
+typedef STRUCT_INT Inttype;
+
+/* corresponding unsigned version */
+typedef unsigned STRUCT_INT Uinttype;
+
+
+/* maximum size (in bytes) for integral types */
+#define MAXINTSIZE	32
+
+/* is 'x' a power of 2? */
+#define isp2(x)		((x) > 0 && ((x) & ((x) - 1)) == 0)
+
+/* dummy structure to get alignment requirements */
+struct cD {
+  char c;
+  double d;
+};
+
+
+#define PADDING		(sizeof(struct cD) - sizeof(double))
+#define MAXALIGN  	(PADDING > sizeof(int) ? PADDING : sizeof(int))
+
+
+/* endian options */
+#define BIG	0
+#define LITTLE	1
+
+
+static union {
+  int dummy;
+  char endian;
+} const native = {1};
+
+
+typedef struct Header {
+  int endian;
+  int align;
+} Header;
+
+
+static int getnum (const char **fmt, int df) {
+  if (!isdigit(**fmt))  /* no number? */
+    return df;  /* return default value */
+  else {
+    int a = 0;
+    do {
+      a = a*10 + *((*fmt)++) - '0';
+    } while (isdigit(**fmt));
+    return a;
+  }
+}
+
+
+#define defaultoptions(h)	((h)->endian = native.endian, (h)->align = 1)
+
+
+
+static size_t optsize (lua_State *L, char opt, const char **fmt) {
+  switch (opt) {
+    case 'B': case 'b': return sizeof(char);
+    case 'H': case 'h': return sizeof(short);
+    case 'L': case 'l': return sizeof(long);
+    case 'T': return sizeof(size_t);
+    case 'f':  return sizeof(float);
+    case 'd':  return sizeof(double);
+    case 'x': return 1;
+    case 'c': return getnum(fmt, 1);
+    case 'i': case 'I': {
+      int sz = getnum(fmt, sizeof(int));
+      if (sz > MAXINTSIZE)
+        luaL_error(L, "integral size %d is larger than limit of %d",
+                       sz, MAXINTSIZE);
+      return sz;
+    }
+    default: return 0;  /* other cases do not need alignment */
+  }
+}
+
+
+/*
+** return number of bytes needed to align an element of size 'size'
+** at current position 'len'
+*/
+static int gettoalign (size_t len, Header *h, int opt, size_t size) {
+  if (size == 0 || opt == 'c') return 0;
+  if (size > (size_t)h->align)
+    size = h->align;  /* respect max. alignment */
+  return (size - (len & (size - 1))) & (size - 1);
+}
+
+
+/*
+** options to control endianess and alignment
+*/
+static void controloptions (lua_State *L, int opt, const char **fmt,
+                            Header *h) {
+  switch (opt) {
+    case  ' ': return;  /* ignore white spaces */
+    case '>': h->endian = BIG; return;
+    case '<': h->endian = LITTLE; return;
+    case '!': {
+      int a = getnum(fmt, MAXALIGN);
+      if (!isp2(a))
+        luaL_error(L, "alignment %d is not a power of 2", a);
+      h->align = a;
+      return;
+    }
+    default: {
+      const char *msg = lua_pushfstring(L, "invalid format option '%c'", opt);
+      luaL_argerror(L, 1, msg);
+    }
+  }
+}
+
+
+static void putinteger (lua_State *L, luaL_Buffer *b, int arg, int endian,
+                        int size) {
+  lua_Number n = luaL_checknumber(L, arg);
+  Uinttype value;
+  char buff[MAXINTSIZE];
+  if (n < 0)
+    value = (Uinttype)(Inttype)n;
+  else
+    value = (Uinttype)n;
+  if (endian == LITTLE) {
+    int i;
+    for (i = 0; i < size; i++) {
+      buff[i] = (value & 0xff);
+      value >>= 8;
+    }
+  }
+  else {
+    int i;
+    for (i = size - 1; i >= 0; i--) {
+      buff[i] = (value & 0xff);
+      value >>= 8;
+    }
+  }
+  luaL_addlstring(b, buff, size);
+}
+
+
+static void correctbytes (char *b, int size, int endian) {
+  if (endian != native.endian) {
+    int i = 0;
+    while (i < --size) {
+      char temp = b[i];
+      b[i++] = b[size];
+      b[size] = temp;
+    }
+  }
+}
+
+
+static int b_pack (lua_State *L) {
+  luaL_Buffer b;
+  const char *fmt = luaL_checkstring(L, 1);
+  Header h;
+  int arg = 2;
+  size_t totalsize = 0;
+  defaultoptions(&h);
+  lua_pushnil(L);  /* mark to separate arguments from string buffer */
+  luaL_buffinit(L, &b);
+  while (*fmt != '\0') {
+    int opt = *fmt++;
+    size_t size = optsize(L, opt, &fmt);
+    int toalign = gettoalign(totalsize, &h, opt, size);
+    totalsize += toalign;
+    while (toalign-- > 0) luaL_addchar(&b, '\0');
+    switch (opt) {
+      case 'b': case 'B': case 'h': case 'H':
+      case 'l': case 'L': case 'T': case 'i': case 'I': {  /* integer types */
+        putinteger(L, &b, arg++, h.endian, size);
+        break;
+      }
+      case 'x': {
+        luaL_addchar(&b, '\0');
+        break;
+      }
+      case 'f': {
+        float f = (float)luaL_checknumber(L, arg++);
+        correctbytes((char *)&f, size, h.endian);
+        luaL_addlstring(&b, (char *)&f, size);
+        break;
+      }
+      case 'd': {
+        double d = luaL_checknumber(L, arg++);
+        correctbytes((char *)&d, size, h.endian);
+        luaL_addlstring(&b, (char *)&d, size);
+        break;
+      }
+      case 'c': case 's': {
+        size_t l;
+        const char *s = luaL_checklstring(L, arg++, &l);
+        if (size == 0) size = l;
+        luaL_argcheck(L, l >= (size_t)size, arg, "string too short");
+        luaL_addlstring(&b, s, size);
+        if (opt == 's') {
+          luaL_addchar(&b, '\0');  /* add zero at the end */
+          size++;
+        }
+        break;
+      }
+      default: controloptions(L, opt, &fmt, &h);
+    }
+    totalsize += size;
+  }
+  luaL_pushresult(&b);
+  return 1;
+}
+
+
+static lua_Number getinteger (const char *buff, int endian,
+                        int issigned, int size) {
+  Uinttype l = 0;
+  int i;
+  if (endian == BIG) {
+    for (i = 0; i < size; i++) {
+      l <<= 8;
+      l |= (Uinttype)(unsigned char)buff[i];
+    }
+  }
+  else {
+    for (i = size - 1; i >= 0; i--) {
+      l <<= 8;
+      l |= (Uinttype)(unsigned char)buff[i];
+    }
+  }
+  if (!issigned)
+    return (lua_Number)l;
+  else {  /* signed format */
+    Uinttype mask = (Uinttype)(~((Uinttype)0)) << (size*8 - 1);
+    if (l & mask)  /* negative value? */
+      l |= mask;  /* signal extension */
+    return (lua_Number)(Inttype)l;
+  }
+}
+
+
+static int b_unpack (lua_State *L) {
+  Header h;
+  const char *fmt = luaL_checkstring(L, 1);
+  size_t ld;
+  const char *data = luaL_checklstring(L, 2, &ld);
+  size_t pos = (size_t)luaL_optinteger(L, 3, 1) - 1;
+  int n = 0;  /* number of results */
+  luaL_argcheck(L, pos <= ld, 3, "initial position out of string");
+  defaultoptions(&h);
+  while (*fmt) {
+    int opt = *fmt++;
+    size_t size = optsize(L, opt, &fmt);
+    pos += gettoalign(pos, &h, opt, size);
+    luaL_argcheck(L, size <= ld - pos, 2, "data string too short");
+    /* stack space for item + next position */
+    luaL_checkstack(L, 2, "too many results");
+    switch (opt) {
+      case 'b': case 'B': case 'h': case 'H':
+      case 'l': case 'L': case 'T': case 'i':  case 'I': {  /* integer types */
+        int issigned = islower(opt);
+        lua_Number res = getinteger(data+pos, h.endian, issigned, size);
+        lua_pushnumber(L, res); n++;
+        break;
+      }
+      case 'x': {
+        break;
+      }
+      case 'f': {
+        float f;
+        memcpy(&f, data+pos, size);
+        correctbytes((char *)&f, sizeof(f), h.endian);
+        lua_pushnumber(L, f); n++;
+        break;
+      }
+      case 'd': {
+        double d;
+        memcpy(&d, data+pos, size);
+        correctbytes((char *)&d, sizeof(d), h.endian);
+        lua_pushnumber(L, d); n++;
+        break;
+      }
+      case 'c': {
+        if (size == 0) {
+          if (n == 0 || !lua_isnumber(L, -1))
+            luaL_error(L, "format 'c0' needs a previous size");
+          size = lua_tonumber(L, -1);
+          lua_pop(L, 1); n--;
+          luaL_argcheck(L, size <= ld - pos, 2, "data string too short");
+        }
+        lua_pushlstring(L, data+pos, size); n++;
+        break;
+      }
+      case 's': {
+        const char *e = (const char *)memchr(data+pos, '\0', ld - pos);
+        if (e == NULL)
+          luaL_error(L, "unfinished string in data");
+        size = (e - (data+pos)) + 1;
+        lua_pushlstring(L, data+pos, size - 1); n++;
+        break;
+      }
+      default: controloptions(L, opt, &fmt, &h);
+    }
+    pos += size;
+  }
+  lua_pushinteger(L, pos + 1);  /* next position */
+  return n + 1;
+}
+
+
+static int b_size (lua_State *L) {
+  Header h;
+  const char *fmt = luaL_checkstring(L, 1);
+  size_t pos = 0;
+  defaultoptions(&h);
+  while (*fmt) {
+    int opt = *fmt++;
+    size_t size = optsize(L, opt, &fmt);
+    pos += gettoalign(pos, &h, opt, size);
+    if (opt == 's')
+      luaL_argerror(L, 1, "option 's' has no fixed size");
+    else if (opt == 'c' && size == 0)
+      luaL_argerror(L, 1, "option 'c0' has no fixed size");
+    if (!isalnum(opt))
+      controloptions(L, opt, &fmt, &h);
+    pos += size;
+  }
+  lua_pushinteger(L, pos);
+  return 1;
+}
+
+/* }====================================================== */
+
+
+
+static const struct luaL_Reg thislib[] = {
+  {"pack", b_pack},
+  {"unpack", b_unpack},
+  {"size", b_size},
+  {NULL, NULL}
+};
+
+
+LUALIB_API int luaopen_util_struct (lua_State *L);
+
+LUALIB_API int luaopen_util_struct (lua_State *L) {
+  luaL_register(L, "struct", thislib);
+  return 1;
+}
+
+
+/******************************************************************************
+* Copyright (C) 2010-2018 Lua.org, PUC-Rio.  All rights reserved.
+*
+* Permission is hereby granted, free of charge, to any person obtaining
+* a copy of this software and associated documentation files (the
+* "Software"), to deal in the Software without restriction, including
+* without limitation the rights to use, copy, modify, merge, publish,
+* distribute, sublicense, and/or sell copies of the Software, and to
+* permit persons to whom the Software is furnished to do so, subject to
+* the following conditions:
+*
+* The above copyright notice and this permission notice shall be
+* included in all copies or substantial portions of the Software.
+*
+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+******************************************************************************/
+
--- a/util-src/time.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/time.c	Mon Dec 12 07:07:13 2022 +0100
@@ -1,22 +1,22 @@
 #ifndef _POSIX_C_SOURCE
-#define _POSIX_C_SOURCE 199309L
+#define _POSIX_C_SOURCE 200809L
 #endif
 
 #include <time.h>
 #include <lua.h>
 
-lua_Number tv2number(struct timespec *tv) {
+static lua_Number tv2number(struct timespec *tv) {
 	return tv->tv_sec + tv->tv_nsec * 1e-9;
 }
 
-int lc_time_realtime(lua_State *L) {
+static int lc_time_realtime(lua_State *L) {
 	struct timespec t;
 	clock_gettime(CLOCK_REALTIME, &t);
 	lua_pushnumber(L, tv2number(&t));
 	return 1;
 }
 
-int lc_time_monotonic(lua_State *L) {
+static int lc_time_monotonic(lua_State *L) {
 	struct timespec t;
 	clock_gettime(CLOCK_MONOTONIC, &t);
 	lua_pushnumber(L, tv2number(&t));
--- a/util-src/windows.c	Mon Dec 12 07:03:31 2022 +0100
+++ b/util-src/windows.c	Mon Dec 12 07:07:13 2022 +0100
@@ -22,6 +22,9 @@
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
 #endif
+#if (LUA_VERSION_NUM < 504)
+#define luaL_pushfail lua_pushnil
+#endif
 
 static int Lget_nameservers(lua_State *L) {
 	char stack_buffer[1024]; // stack allocated buffer
@@ -45,14 +48,14 @@
 
 		return 1;
 	} else {
-		lua_pushnil(L);
+		luaL_pushfail(L);
 		lua_pushfstring(L, "DnsQueryConfig returned %d", status);
 		return 2;
 	}
 }
 
 static int lerror(lua_State *L, char *string) {
-	lua_pushnil(L);
+	luaL_pushfail(L);
 	lua_pushfstring(L, "%s: %d", string, GetLastError());
 	return 2;
 }
--- a/util/adhoc.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/adhoc.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,7 +2,7 @@
 
 local function new_simple_form(form, result_handler)
 	return function(self, data, state)
-		if state then
+		if state or data.form then
 			if data.action == "cancel" then
 				return { status = "canceled" };
 			end
@@ -16,15 +16,21 @@
 
 local function new_initial_data_form(form, initial_data, result_handler)
 	return function(self, data, state)
-		if state then
+		if state or data.form then
 			if data.action == "cancel" then
 				return { status = "canceled" };
 			end
 			local fields, err = form:data(data.form);
 			return result_handler(fields, err, data);
 		else
+			local values, err = initial_data(data);
+			if type(err) == "table" then
+				return {status = "error"; error = err}
+			elseif type(err) == "string" then
+				return {status = "error"; error = {type = "cancel"; condition = "internal-server-error", err}}
+			end
 			return { status = "executing", actions = {"next", "complete", default = "complete"},
-				 form = { layout = form, values = initial_data(data) } }, "executing";
+				 form = { layout = form, values = values } }, "executing";
 		end
 	end
 end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/adminstream.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,346 @@
+local st = require "util.stanza";
+local new_xmpp_stream = require "util.xmppstream".new;
+local sessionlib = require "util.session";
+local gettime = require "util.time".now;
+local runner = require "util.async".runner;
+local add_task = require "util.timer".add_task;
+local events = require "util.events";
+local server = require "net.server";
+
+local stream_close_timeout = 5;
+
+local log = require "util.logger".init("adminstream");
+
+local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
+
+local stream_callbacks = { default_ns = "xmpp:prosody.im/admin" };
+
+function stream_callbacks.streamopened(session, attr)
+	-- run _streamopened in async context
+	session.thread:run({ stream = "opened", attr = attr });
+end
+
+function stream_callbacks._streamopened(session, attr) --luacheck: ignore 212/attr
+	if session.type ~= "client" then
+		session:open_stream();
+	end
+	session.notopen = nil;
+end
+
+function stream_callbacks.streamclosed(session, attr)
+	-- run _streamclosed in async context
+	session.thread:run({ stream = "closed", attr = attr });
+end
+
+function stream_callbacks._streamclosed(session)
+	session.log("debug", "Received </stream:stream>");
+	session:close(false);
+end
+
+function stream_callbacks.error(session, error, data)
+	if error == "no-stream" then
+		session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
+		session:close("invalid-namespace");
+	elseif error == "parse-error" then
+		session.log("debug", "Client XML parse error: %s", data);
+		session:close("not-well-formed");
+	elseif error == "stream-error" then
+		local condition, text = "undefined-condition";
+		for child in data:childtags(nil, xmlns_xmpp_streams) do
+			if child.name ~= "text" then
+				condition = child.name;
+			else
+				text = child:get_text();
+			end
+			if condition ~= "undefined-condition" and text then
+				break;
+			end
+		end
+		text = condition .. (text and (" ("..text..")") or "");
+		session.log("info", "Session closed by remote with error: %s", text);
+		session:close(nil, text);
+	end
+end
+
+function stream_callbacks.handlestanza(session, stanza)
+	session.thread:run(stanza);
+end
+
+local runner_callbacks = {};
+
+function runner_callbacks:error(err)
+	self.data.log("error", "Traceback[c2s]: %s", err);
+end
+
+local stream_xmlns_attr = {xmlns='urn:ietf:params:xml:ns:xmpp-streams'};
+
+local function destroy_session(session, reason)
+	if session.destroyed then return; end
+	session.destroyed = true;
+	session.log("debug", "Destroying session: %s", reason or "unknown reason");
+end
+
+local function session_close(session, reason)
+	local log = session.log or log;
+	if session.conn then
+		if session.notopen then
+			session:open_stream();
+		end
+		if reason then -- nil == no err, initiated by us, false == initiated by client
+			local stream_error = st.stanza("stream:error");
+			if type(reason) == "string" then -- assume stream error
+				stream_error:tag(reason, {xmlns = 'urn:ietf:params:xml:ns:xmpp-streams' });
+			elseif type(reason) == "table" then
+				if reason.condition then
+					stream_error:tag(reason.condition, stream_xmlns_attr):up();
+					if reason.text then
+						stream_error:tag("text", stream_xmlns_attr):text(reason.text):up();
+					end
+					if reason.extra then
+						stream_error:add_child(reason.extra);
+					end
+				elseif reason.name then -- a stanza
+					stream_error = reason;
+				end
+			end
+			stream_error = tostring(stream_error);
+			log("debug", "Disconnecting client, <stream:error> is: %s", stream_error);
+			session.send(stream_error);
+		end
+
+		session.send("</stream:stream>");
+		function session.send() return false; end
+
+		local reason_text = (reason and (reason.name or reason.text or reason.condition)) or reason;
+		session.log("debug", "c2s stream for %s closed: %s", session.full_jid or session.ip or "<unknown>", reason_text or "session closed");
+
+		-- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote
+		local conn = session.conn;
+		if reason_text == nil and not session.notopen and session.type == "c2s" then
+			-- Grace time to process data from authenticated cleanly-closed stream
+			add_task(stream_close_timeout, function ()
+				if not session.destroyed then
+					session.log("warn", "Failed to receive a stream close response, closing connection anyway...");
+					destroy_session(session);
+					conn:close();
+				end
+			end);
+		else
+			destroy_session(session, reason_text);
+			conn:close();
+		end
+	else
+		local reason_text = (reason and (reason.name or reason.text or reason.condition)) or reason;
+		destroy_session(session, reason_text);
+	end
+end
+
+--- Public methods
+
+local function new_connection(socket_path, listeners)
+	local have_unix, unix = pcall(require, "socket.unix");
+	if have_unix and type(unix) == "function" then
+		-- COMPAT #1717
+		-- Before the introduction of datagram support, only the stream socket
+		-- constructor was exported instead of a module table. Due to the lack of a
+		-- proper release of LuaSocket, distros have settled on shipping either the
+		-- last RC tag or some commit since then.
+		-- Here we accomodate both variants.
+		unix = { stream = unix };
+	end
+	if type(unix) ~= "table" then
+		have_unix = false;
+	end
+	local conn, sock;
+
+	return {
+		connect = function ()
+			if not have_unix then
+				return nil, "no unix socket support";
+			end
+			if sock or conn then
+				return nil, "already connected";
+			end
+			sock = unix.stream();
+			sock:settimeout(0);
+			local ok, err = sock:connect(socket_path);
+			if not ok then
+				return nil, err;
+			end
+			conn = server.wrapclient(sock, nil, nil, listeners, "*a");
+			return true;
+		end;
+		disconnect = function ()
+			if conn then
+				conn:close();
+				conn = nil;
+			end
+			if sock then
+				sock:close();
+				sock = nil;
+			end
+			return true;
+		end;
+	};
+end
+
+local function new_server(sessions, stanza_handler)
+	local listeners = {};
+
+	function listeners.onconnect(conn)
+		log("debug", "New connection");
+		local session = sessionlib.new("admin");
+		sessionlib.set_id(session);
+		sessionlib.set_logger(session);
+		sessionlib.set_conn(session, conn);
+
+		session.conntime = gettime();
+		session.type = "admin";
+
+		local stream = new_xmpp_stream(session, stream_callbacks);
+		session.stream = stream;
+		session.notopen = true;
+
+		session.thread = runner(function (stanza)
+			if st.is_stanza(stanza) then
+				stanza_handler(session, stanza);
+			elseif stanza.stream == "opened" then
+				stream_callbacks._streamopened(session, stanza.attr);
+			elseif stanza.stream == "closed" then
+				stream_callbacks._streamclosed(session, stanza.attr);
+			end
+		end, runner_callbacks, session);
+
+		function session.data(data)
+			-- Parse the data, which will store stanzas in session.pending_stanzas
+			if data then
+				local ok, err = stream:feed(data);
+				if not ok then
+					session.log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
+					session:close("not-well-formed");
+				end
+			end
+		end
+
+		session.close = session_close;
+
+		session.send = function (t)
+			session.log("debug", "Sending[%s]: %s", session.type, t.top_tag and t:top_tag() or t:match("^[^>]*>?"));
+			return session.rawsend(tostring(t));
+		end
+
+		function session.rawsend(t)
+			local ret, err = conn:write(t);
+			if not ret then
+				session.log("debug", "Error writing to connection: %s", err);
+				return false, err;
+			end
+			return true;
+		end
+
+		sessions[conn] = session;
+	end
+
+	function listeners.onincoming(conn, data)
+		local session = sessions[conn];
+		if session then
+			session.data(data);
+		end
+	end
+
+	function listeners.ondisconnect(conn, err)
+		local session = sessions[conn];
+		if session then
+			session.log("info", "Admin client disconnected: %s", err or "connection closed");
+			session.conn = nil;
+			sessions[conn]  = nil;
+		end
+	end
+
+	function listeners.onreadtimeout(conn)
+		return conn:send(" ");
+	end
+
+	return {
+		listeners = listeners;
+	};
+end
+
+local function new_client()
+	local client = {
+		type = "client";
+		events = events.new();
+		log = log;
+	};
+
+	local listeners = {};
+
+	function listeners.onconnect(conn)
+		log("debug", "Connected");
+		client.conn = conn;
+
+		local stream = new_xmpp_stream(client, stream_callbacks);
+		client.stream = stream;
+		client.notopen = true;
+
+		client.thread = runner(function (stanza)
+			if st.is_stanza(stanza) then
+				if not client.events.fire_event("received", stanza) and not stanza.attr.xmlns then
+					client.events.fire_event("received/"..stanza.name, stanza);
+				end
+			elseif stanza.stream == "opened" then
+				stream_callbacks._streamopened(client, stanza.attr);
+				client.events.fire_event("connected");
+			elseif stanza.stream == "closed" then
+				client.events.fire_event("disconnected");
+				stream_callbacks._streamclosed(client, stanza.attr);
+			end
+		end, runner_callbacks, client);
+
+		client.close = session_close;
+
+		function client.send(t)
+			client.log("debug", "Sending: %s", t.top_tag and t:top_tag() or t:match("^[^>]*>?"));
+			return client.rawsend(tostring(t));
+		end
+
+		function client.rawsend(t)
+			local ret, err = conn:write(t);
+			if not ret then
+				client.log("debug", "Error writing to connection: %s", err);
+				return false, err;
+			end
+			return true;
+		end
+		client.log("debug", "Opening stream...");
+		client:open_stream();
+	end
+
+	function listeners.onincoming(conn, data) --luacheck: ignore 212/conn
+		local ok, err = client.stream:feed(data);
+		if not ok then
+			client.log("debug", "Received invalid XML (%s) %d bytes: %q", err, #data, data:sub(1, 300));
+			client:close("not-well-formed");
+		end
+	end
+
+	function listeners.ondisconnect(conn, err) --luacheck: ignore 212/conn
+		client.log("info", "Admin client disconnected: %s", err or "connection closed");
+		client.conn = nil;
+		client.events.fire_event("disconnected");
+	end
+
+	function listeners.onreadtimeout(conn)
+		conn:send(" ");
+	end
+
+	client.listeners = listeners;
+
+	return client;
+end
+
+return {
+	connection = new_connection;
+	server = new_server;
+	client = new_client;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/argparse.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,58 @@
+local function parse(arg, config)
+	local short_params = config and config.short_params or {};
+	local value_params = config and config.value_params or {};
+
+	local parsed_opts = {};
+
+	if #arg == 0 then
+		return parsed_opts;
+	end
+	while true do
+		local raw_param = arg[1];
+		if not raw_param then
+			break;
+		end
+
+		local prefix = raw_param:match("^%-%-?");
+		if not prefix then
+			break;
+		elseif prefix == "--" and raw_param == "--" then
+			table.remove(arg, 1);
+			break;
+		end
+		local param = table.remove(arg, 1):sub(#prefix+1);
+		if #param == 1 and short_params then
+			param = short_params[param];
+		end
+
+		if not param then
+			return nil, "param-not-found", raw_param;
+		end
+
+		local param_k, param_v;
+		if value_params[param] then
+			param_k, param_v = param, table.remove(arg, 1);
+			if not param_v then
+				return nil, "missing-value", raw_param;
+			end
+		else
+			param_k, param_v = param:match("^([^=]+)=(.+)$");
+			if not param_k then
+				if param:match("^no%-") then
+					param_k, param_v = param:sub(4), false;
+				else
+					param_k, param_v = param, true;
+				end
+			end
+		end
+		parsed_opts[param_k] = param_v;
+	end
+	for i = 1, #arg do
+		parsed_opts[i] = arg[i];
+	end
+	return parsed_opts;
+end
+
+return {
+	parse = parse;
+}
--- a/util/array.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/array.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,6 +10,7 @@
     = table.insert, table.sort, table.remove, table.concat;
 
 local setmetatable = setmetatable;
+local getmetatable = getmetatable;
 local math_random = math.random;
 local math_floor = math.floor;
 local pairs, ipairs = pairs, ipairs;
@@ -40,6 +41,10 @@
 end
 
 function array_mt.__eq(a, b)
+	if getmetatable(a) ~= array_mt or getmetatable(b) ~= array_mt then
+		-- Lua 5.3+ calls this if both operands are tables, even if metatables differ
+		return false;
+	end
 	if #a == #b then
 		for i = 1, #a do
 			if a[i] ~= b[i] then
@@ -109,6 +114,40 @@
 	return outa;
 end
 
+function array_base.slice(outa, ina, i, j)
+	if j == nil then
+		j = -1;
+	end
+	if j < 0 then
+		j = #ina + (j+1);
+	end
+	if i < 0 then
+		i = #ina + (i+1);
+	end
+	if i < 1 then
+		i = 1;
+	end
+	if j > #ina then
+		j = #ina;
+	end
+	if i > j then
+		for idx = 1, #outa do
+			outa[idx] = nil;
+		end
+		return outa;
+	end
+
+	for idx = 1, 1+j-i do
+		outa[idx] = ina[i+(idx-1)];
+	end
+	if ina == outa then
+		for idx = 2+j-i, #outa do
+			outa[idx] = nil;
+		end
+	end
+	return outa;
+end
+
 function array_base.sort(outa, ina, ...)
 	if ina ~= outa then
 		outa:append(ina);
@@ -129,9 +168,13 @@
 	end);
 end
 
-function array_base.pluck(outa, ina, key)
+function array_base.pluck(outa, ina, key, default)
 	for i = 1, #ina do
-		outa[i] = ina[i][key];
+		local v = ina[i][key];
+		if v == nil then
+			v = default;
+		end
+		outa[i] = v;
 	end
 	return outa;
 end
--- a/util/async.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/async.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -11,6 +11,12 @@
 	return thread;
 end
 
+-- Configurable functions
+local schedule_task = nil; -- schedule_task(seconds, callback)
+local next_tick = function (f)
+	f();
+end
+
 local function runner_from_thread(thread)
 	local level = 0;
 	-- Find the 'level' of the top-most function (0 == current level, 1 == caller, ...)
@@ -53,19 +59,21 @@
 			return false;
 		end
 		call_watcher(runner, "error", debug.traceback(thread, err));
-		runner.state, runner.thread = "ready", nil;
+		runner.state = "ready";
 		return runner:run();
 	elseif state == "ready" then
 		-- If state is 'ready', it is our responsibility to update runner.state from 'waiting'.
 		-- We also have to :run(), because the queue might have further items that will not be
 		-- processed otherwise. FIXME: It's probably best to do this in a nexttick (0 timer).
-		runner.state = "ready";
-		runner:run();
+		next_tick(function ()
+			runner.state = "ready";
+			runner:run();
+		end);
 	end
 	return true;
 end
 
-local function waiter(num)
+local function waiter(num, allow_many)
 	local thread = checkthread();
 	num = num or 1;
 	local waiting;
@@ -77,7 +85,7 @@
 		num = num - 1;
 		if num == 0 and waiting then
 			runner_continue(thread);
-		elseif num < 0 then
+		elseif not allow_many and num < 0 then
 			error("done() called too many times");
 		end
 	end;
@@ -118,6 +126,15 @@
 	end;
 end
 
+local function sleep(seconds)
+	if not schedule_task then
+		error("async.sleep() is not available - configure schedule function");
+	end
+	local wait, done = waiter();
+	schedule_task(seconds, done);
+	wait();
+end
+
 local runner_mt = {};
 runner_mt.__index = runner_mt;
 
@@ -159,6 +176,10 @@
 
 	local q, thread = self.queue, self.thread;
 	if not thread or coroutine.status(thread) == "dead" then
+		--luacheck: ignore 143/coroutine
+		if thread and coroutine.close then
+			coroutine.close(thread);
+		end
 		self:log("debug", "creating new coroutine");
 		-- Create a new coroutine for this runner
 		thread = runner_create_thread(self.func, self);
@@ -246,9 +267,30 @@
 	return pcall(checkthread);
 end
 
+local function wait_for(promise)
+	local async_wait, async_done = waiter();
+	local ret, err = nil, nil;
+	promise:next(
+		function (r) ret = r; end,
+		function (e) err = e; end)
+		:finally(async_done);
+	async_wait();
+	if ret then
+		return ret;
+	else
+		return nil, err;
+	end
+end
+
 return {
 	ready = ready;
 	waiter = waiter;
 	guarder = guarder;
 	runner = runner;
+	wait = wait_for; -- COMPAT w/trunk pre-0.12
+	wait_for = wait_for;
+	sleep = sleep;
+
+	set_nexttick = function(new_next_tick) next_tick = new_next_tick; end;
+	set_schedule_function = function (new_schedule_function) schedule_task = new_schedule_function; end;
 };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/bit53.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,33 @@
+-- Only the operators needed by net.websocket.frames are provided at this point
+return {
+	band   = function (a, b, ...)
+		local ret = a & b;
+		if ... then
+			for i = 1, select("#", ...) do
+				ret = ret & (select(i, ...));
+			end
+		end
+		return ret;
+	end;
+	bor    = function (a, b, ...)
+		local ret = a | b;
+		if ... then
+			for i = 1, select("#", ...) do
+				ret = ret | (select(i, ...));
+			end
+		end
+		return ret;
+	end;
+	bxor   = function (a, b, ...)
+		local ret = a ~ b;
+		if ... then
+			for i = 1, select("#", ...) do
+				ret = ret ~ (select(i, ...));
+			end
+		end
+		return ret;
+	end;
+	rshift = function (a, n) return a >> n end;
+	lshift = function (a, n) return a << n end;
+};
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/bitcompat.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,32 @@
+-- Compatibility layer for bitwise operations
+
+-- First try the bit32 lib
+-- Lua 5.3 has it with compat enabled
+-- Lua 5.2 has it by default
+if _G.bit32 then
+	return _G.bit32;
+else
+	-- Lua 5.1 may have it as a standalone module that can be installed
+	local ok, bitop = pcall(require, "bit32")
+	if ok then
+		return bitop;
+	end
+end
+
+do
+	-- Lua 5.3 and 5.4 would be able to use native infix operators
+	local ok, bitop = pcall(require, "util.bit53")
+	if ok then
+		return bitop;
+	end
+end
+
+do
+	-- Lastly, try the LuaJIT bitop library
+	local ok, bitop = pcall(require, "bit")
+	if ok then
+		return bitop;
+	end
+end
+
+error "No bit module found. See https://prosody.im/doc/depends#bitop";
--- a/util/cache.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/cache.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -28,7 +28,7 @@
 end
 
 local cache_methods = {};
-local cache_mt = { __index = cache_methods };
+local cache_mt = { __name = "cache", __index = cache_methods };
 
 function cache_methods:set(k, v)
 	local m = self._data[k];
--- a/util/dataforms.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/dataforms.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,9 +10,11 @@
 local ipairs = ipairs;
 local type, next = type, next;
 local tonumber = tonumber;
+local tostring = tostring;
 local t_concat = table.concat;
 local st = require "util.stanza";
 local jid_prep = require "util.jid".prep;
+local datetime = require "util.datetime";
 
 local _ENV = nil;
 -- luacheck: std none
@@ -54,6 +56,12 @@
 
 		if formtype == "form" and field.datatype then
 			form:tag("validate", { xmlns = xmlns_validate, datatype = field.datatype });
+			if field.range_min or field.range_max then
+				form:tag("range", {
+						min = field.range_min and tostring(field.range_min),
+						max = field.range_max and tostring(field.range_max),
+					}):up();
+			end
 			-- <basic/> assumed
 			form:up();
 		end
@@ -95,8 +103,15 @@
 
 		if value ~= nil then
 			if type(value) == "number" then
-				-- TODO validate that this is ok somehow, eg check field.datatype
-				value = ("%g"):format(value);
+				if field.datatype == "xs:dateTime" then
+					value = datetime.datetime(value);
+				elseif field_type == "boolean" then
+					value = value ~= 0;
+				elseif field.datatype == "xs:double" or field.datatype == "xs:decimal" then
+					value = ("%f"):format(value);
+				else
+					value = ("%d"):format(value);
+				end
 			end
 			-- Add value, depending on type
 			if field_type == "hidden" then
@@ -136,7 +151,7 @@
 
 		local media = field.media;
 		if media then
-			form:tag("media", { xmlns = "urn:xmpp:media-element", height = media.height, width = media.width });
+			form:tag("media", { xmlns = "urn:xmpp:media-element", height = ("%d"):format(media.height), width = ("%d"):format(media.width) });
 			for _, val in ipairs(media) do
 				form:tag("uri", { type = val.type }):text(val.uri):up()
 			end
@@ -290,13 +305,34 @@
 	end
 
 data_validators["xs:integer"] =
-	function (data)
+	function (data, field)
 		local n = tonumber(data);
 		if not n then
 			return false, "not a number";
 		elseif n % 1 ~= 0 then
 			return false, "not an integer";
 		end
+		if field.range_max and n > field.range_max then
+			return false, "out of bounds";
+		elseif field.range_min and n < field.range_min then
+			return false, "out of bounds";
+		end
+		return true, n;
+	end
+
+data_validators["pubsub:integer-or-max"] =
+	function (data, field)
+		if data == "max" then
+			return true, data;
+		else
+			return data_validators["xs:integer"](data, field);
+		end
+	end
+
+data_validators["xs:dateTime"] =
+	function(data, field) -- luacheck: ignore 212/field
+		local n = datetime.parse(data);
+		if not n then return false, "invalid timestamp"; end
 		return true, n;
 	end
 
--- a/util/datamanager.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/datamanager.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -24,7 +24,7 @@
 local envloadfile = require"util.envload".envloadfile;
 local serialize = require "util.serialization".serialize;
 local lfs = require "lfs";
--- Extract directory seperator from package.config (an undocumented string that comes with lua)
+-- Extract directory separator from package.config (an undocumented string that comes with lua)
 local path_separator = assert ( package.config:match ( "^([^\n]+)" ) , "package.config not in standard form" )
 
 local prosody = prosody;
@@ -157,7 +157,8 @@
 
 local function atomic_store(filename, data)
 	local scratch = filename.."~";
-	local f, ok, msg, errno;
+	local f, ok, msg, errno; -- luacheck: ignore errno
+	-- TODO return util.error with code=errno?
 
 	f, msg, errno = io_open(scratch, "w");
 	if not f then
@@ -221,7 +222,7 @@
 			os_remove(getpath(username, host, datastore));
 		end
 		-- we write data even when we are deleting because lua doesn't have a
-		-- platform independent way of checking for non-exisitng files
+		-- platform independent way of checking for nonexisting files
 	until ok;
 	return true;
 end
@@ -289,7 +290,7 @@
 		os_remove(getpath(username, host, datastore, "list"));
 	end
 	-- we write data even when we are deleting because lua doesn't have a
-	-- platform independent way of checking for non-exisitng files
+	-- platform independent way of checking for nonexisting files
 	return true;
 end
 
@@ -319,7 +320,7 @@
 }
 
 local function users(host, store, typ) -- luacheck: ignore 431/store
-	typ = type_map[typ or "keyval"];
+	typ = "."..(type_map[typ or "keyval"] or typ);
 	local store_dir = format("%s/%s/%s", data_path, encode(host), store_encode(store));
 
 	local mode, err = lfs.attributes(store_dir, "mode");
@@ -329,9 +330,8 @@
 	local next, state = lfs.dir(store_dir); -- luacheck: ignore 431/next 431/state
 	return function(state) -- luacheck: ignore 431/state
 		for node in next, state do
-			local file, ext = node:match("^(.*)%.([dalist]+)$");
-			if file and ext == typ then
-				return decode(file);
+			if node:sub(-#typ, -1) == typ then
+				return decode(node:sub(1, -#typ-1));
 			end
 		end
 	end, state;
@@ -343,7 +343,7 @@
 
 	local mode, err = lfs.attributes(store_dir, "mode");
 	if not mode then
-		return function() log("debug", err or (store_dir .. " does not exist")) end
+		return function() log("debug", "Could not iterate over stores in %s: %s", store_dir, err); end
 	end
 	local next, state = lfs.dir(store_dir); -- luacheck: ignore 431/next 431/state
 	return function(state) -- luacheck: ignore 431/state
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/datamapper.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,349 @@
+-- This file is generated from teal-src/util/datamapper.lua
+
+local st = require("util.stanza");
+local pointer = require("util.jsonpointer");
+
+local schema_t = {}
+
+local function toboolean(s)
+	if s == "true" or s == "1" then
+		return true
+	elseif s == "false" or s == "0" then
+		return false
+	elseif s then
+		return true
+	end
+end
+
+local function totype(t, s)
+	if not s then
+		return nil
+	end
+	if t == "string" then
+		return s
+	elseif t == "boolean" then
+		return toboolean(s)
+	elseif t == "number" or t == "integer" then
+		return tonumber(s)
+	end
+end
+
+local value_goes = {}
+
+local function resolve_schema(schema, root)
+	if type(schema) == "table" then
+		if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
+			return pointer.resolve(root, schema["$ref"]:sub(2))
+		end
+	end
+	return schema
+end
+
+local function guess_schema_type(schema)
+	local schema_types = schema.type
+	if type(schema_types) == "string" then
+		return schema_types
+	elseif schema_types ~= nil then
+		error("schema has unsupported 'type' property")
+	elseif schema.properties then
+		return "object"
+	elseif schema.items then
+		return "array"
+	end
+	return "string"
+end
+
+local function unpack_propschema(propschema, propname, current_ns)
+
+	local proptype = "string"
+	local value_where = propname and "in_text_tag" or "in_text"
+	local name = propname
+	local namespace
+	local prefix
+	local single_attribute
+	local enums
+
+	if type(propschema) == "table" then
+		proptype = guess_schema_type(propschema);
+	elseif type(propschema) == "string" then
+		error("schema as string is not supported: " .. propschema .. " {" .. current_ns .. "}" .. propname)
+	end
+
+	if proptype == "object" or proptype == "array" then
+		value_where = "in_children"
+	end
+
+	if type(propschema) == "table" then
+		local xml = propschema.xml
+		if xml then
+			if xml.name then
+				name = xml.name
+			end
+			if xml.namespace and xml.namespace ~= current_ns then
+				namespace = xml.namespace
+			end
+			if xml.prefix then
+				prefix = xml.prefix
+			end
+			if proptype == "array" and xml.wrapped then
+				value_where = "in_wrapper"
+			elseif xml.attribute then
+				value_where = "in_attribute"
+			elseif xml.text then
+				value_where = "in_text"
+			elseif xml.x_name_is_value then
+				value_where = "in_tag_name"
+			elseif xml.x_single_attribute then
+				single_attribute = xml.x_single_attribute
+				value_where = "in_single_attribute"
+			end
+		end
+		if propschema["const"] then
+			enums = {propschema["const"]}
+		elseif propschema["enum"] then
+			enums = propschema["enum"]
+		end
+	end
+
+	return proptype, value_where, name, namespace, prefix, single_attribute, enums
+end
+
+local parse_object
+local parse_array
+
+local function extract_value(s, value_where, proptype, name, namespace, prefix, single_attribute, enums)
+	if value_where == "in_tag_name" then
+		local c
+		if proptype == "boolean" then
+			c = s:get_child(name, namespace);
+		elseif enums and proptype == "string" then
+
+			for i = 1, #enums do
+				c = s:get_child(enums[i], namespace);
+				if c then
+					break
+				end
+			end
+		else
+			c = s:get_child(nil, namespace);
+		end
+		if c then
+			return c.name
+		end
+	elseif value_where == "in_attribute" then
+		local attr = name
+		if prefix then
+			attr = prefix .. ":" .. name
+		elseif namespace and namespace ~= s.attr.xmlns then
+			attr = namespace .. "\1" .. name
+		end
+		return s.attr[attr]
+
+	elseif value_where == "in_text" then
+		return s:get_text()
+
+	elseif value_where == "in_single_attribute" then
+		local c = s:get_child(name, namespace)
+		return c and c.attr[single_attribute]
+	elseif value_where == "in_text_tag" then
+		return s:get_child_text(name, namespace)
+	end
+end
+
+function parse_object(schema, s, root)
+	local out = {}
+	schema = resolve_schema(schema, root)
+	if type(schema) == "table" and schema.properties then
+		for prop, propschema in pairs(schema.properties) do
+			propschema = resolve_schema(propschema, root)
+
+			local proptype, value_where, name, namespace, prefix, single_attribute, enums = unpack_propschema(propschema, prop, s.attr.xmlns)
+
+			if value_where == "in_children" and type(propschema) == "table" then
+				if proptype == "object" then
+					local c = s:get_child(name, namespace)
+					if c then
+						out[prop] = parse_object(propschema, c, root);
+					end
+				elseif proptype == "array" then
+					local a = parse_array(propschema, s, root);
+					if a and a[1] ~= nil then
+						out[prop] = a;
+					end
+				else
+					error("unreachable")
+				end
+			elseif value_where == "in_wrapper" and type(propschema) == "table" and proptype == "array" then
+				local wrapper = s:get_child(name, namespace);
+				if wrapper then
+					out[prop] = parse_array(propschema, wrapper, root);
+				end
+			else
+				local value = extract_value(s, value_where, proptype, name, namespace, prefix, single_attribute, enums)
+
+				out[prop] = totype(proptype, value)
+			end
+		end
+	end
+
+	return out
+end
+
+function parse_array(schema, s, root)
+	local itemschema = resolve_schema(schema.items, root);
+	local proptype, value_where, child_name, namespace, prefix, single_attribute, enums = unpack_propschema(itemschema, nil, s.attr.xmlns)
+	local attr_name
+	if value_where == "in_single_attribute" then
+		value_where = "in_attribute";
+		attr_name = single_attribute;
+	end
+	local out = {}
+
+	if proptype == "object" then
+		if type(itemschema) == "table" then
+			for c in s:childtags(child_name, namespace) do
+				table.insert(out, parse_object(itemschema, c, root));
+			end
+		else
+			error("array items must be schema object")
+		end
+	elseif proptype == "array" then
+		if type(itemschema) == "table" then
+			for c in s:childtags(child_name, namespace) do
+				table.insert(out, parse_array(itemschema, c, root));
+			end
+		end
+	else
+		for c in s:childtags(child_name, namespace) do
+			local value = extract_value(c, value_where, proptype, attr_name or child_name, namespace, prefix, single_attribute, enums)
+
+			table.insert(out, totype(proptype, value));
+		end
+	end
+	return out
+end
+
+local function parse(schema, s)
+	local s_type = guess_schema_type(schema)
+	if s_type == "object" then
+		return parse_object(schema, s, schema)
+	elseif s_type == "array" then
+		return parse_array(schema, s, schema)
+	else
+		error("top-level scalars unsupported")
+	end
+end
+
+local function toxmlstring(proptype, v)
+	if proptype == "string" and type(v) == "string" then
+		return v
+	elseif proptype == "number" and type(v) == "number" then
+		return string.format("%g", v)
+	elseif proptype == "integer" and type(v) == "number" then
+		return string.format("%d", v)
+	elseif proptype == "boolean" then
+		return v and "1" or "0"
+	end
+end
+
+local unparse
+
+local function unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix,
+	single_attribute, root)
+
+	if value_where == "in_attribute" then
+		local attr = name
+		if prefix then
+			attr = prefix .. ":" .. name
+		elseif namespace and namespace ~= current_ns then
+			attr = namespace .. "\1" .. name
+		end
+
+		out.attr[attr] = toxmlstring(proptype, v)
+	elseif value_where == "in_text" then
+		out:text(toxmlstring(proptype, v))
+	elseif value_where == "in_single_attribute" then
+		assert(single_attribute)
+		local propattr = {}
+
+		if namespace and namespace ~= current_ns then
+			propattr.xmlns = namespace
+		end
+
+		propattr[single_attribute] = toxmlstring(proptype, v)
+		out:tag(name, propattr):up();
+
+	else
+		local propattr
+		if namespace ~= current_ns then
+			propattr = {xmlns = namespace}
+		end
+		if value_where == "in_tag_name" then
+			if proptype == "string" and type(v) == "string" then
+				out:tag(v, propattr):up();
+			elseif proptype == "boolean" and v == true then
+				out:tag(name, propattr):up();
+			end
+		elseif proptype == "object" and type(propschema) == "table" and type(v) == "table" then
+			local c = unparse(propschema, v, name, namespace, nil, root);
+			if c then
+				out:add_direct_child(c);
+			end
+		elseif proptype == "array" and type(propschema) == "table" and type(v) == "table" then
+			if value_where == "in_wrapper" then
+				local c = unparse(propschema, v, name, namespace, nil, root);
+				if c then
+					out:add_direct_child(c);
+				end
+			else
+				unparse(propschema, v, name, namespace, out, root);
+			end
+		else
+			out:text_tag(name, toxmlstring(proptype, v), propattr)
+		end
+	end
+end
+
+function unparse(schema, t, current_name, current_ns, ctx, root)
+
+	if root == nil then
+		root = schema
+	end
+
+	if schema.xml then
+		if schema.xml.name then
+			current_name = schema.xml.name
+		end
+		if schema.xml.namespace then
+			current_ns = schema.xml.namespace
+		end
+
+	end
+
+	local out = ctx or st.stanza(current_name, {xmlns = current_ns})
+
+	local s_type = guess_schema_type(schema)
+	if s_type == "object" then
+
+		for prop, propschema in pairs(schema.properties) do
+			propschema = resolve_schema(propschema, root)
+			local v = t[prop]
+
+			if v ~= nil then
+				local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(propschema, prop, current_ns)
+				unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
+			end
+		end
+		return out
+
+	elseif s_type == "array" then
+		local itemschema = resolve_schema(schema.items, root)
+		local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(itemschema, current_name, current_ns)
+		for _, item in ipairs(t) do
+			unparse_property(out, item, proptype, itemschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
+		end
+		return out
+	end
+end
+
+return {parse = parse; unparse = unparse}
--- a/util/dbuffer.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/dbuffer.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,7 +2,7 @@
 
 local s_byte, s_sub = string.byte, string.sub;
 local dbuffer_methods = {};
-local dynamic_buffer_mt = { __index = dbuffer_methods };
+local dynamic_buffer_mt = { __name = "dbuffer", __index = dbuffer_methods };
 
 function dbuffer_methods:write(data)
 	if self.max_size and #data + self._length > self.max_size then
@@ -76,6 +76,20 @@
 	return table.concat(chunks);
 end
 
+-- Read to, and including, the specified character sequence (return nil if not found)
+function dbuffer_methods:read_until(char)
+	local buffer_pos = 0;
+	for i, chunk in self.items:items() do
+		local start = 1 + ((i == 1) and self.front_consumed or 0);
+		local char_pos = chunk:find(char, start, true);
+		if char_pos then
+			return self:read(1 + buffer_pos + char_pos - start);
+		end
+		buffer_pos = buffer_pos + #chunk - (start - 1);
+	end
+	return nil;
+end
+
 function dbuffer_methods:discard(requested_bytes)
 	if requested_bytes > self._length then
 		return nil;
--- a/util/dependencies.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/dependencies.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,24 +7,22 @@
 --
 
 local function softreq(...) local ok, lib =  pcall(require, ...); if ok then return lib; else return nil, lib; end end
+local platform_table = require "util.human.io".table({ { width = 15, align = "right" }, { width = "100%" } });
 
 -- Required to be able to find packages installed with luarocks
 if not softreq "luarocks.loader" then -- LuaRocks 2.x
 	softreq "luarocks.require"; -- LuaRocks <1.x
 end
 
-local function missingdep(name, sources, msg)
+local function missingdep(name, sources, msg, err) -- luacheck: ignore err
+	-- TODO print something about the underlying error, useful for debugging
 	print("");
 	print("**************************");
 	print("Prosody was unable to find "..tostring(name));
 	print("This package can be obtained in the following ways:");
 	print("");
-	local longest_platform = 0;
-	for platform in pairs(sources) do
-		longest_platform = math.max(longest_platform, #platform);
-	end
-	for platform, source in pairs(sources) do
-		print("", platform..":"..(" "):rep(4+longest_platform-#platform)..source);
+	for _, row in ipairs(sources) do
+		print(platform_table(row));
 	end
 	print("");
 	print(msg or (name.." is required for Prosody to run, so we will now exit."));
@@ -44,25 +42,25 @@
 
 	local fatal;
 
-	local lxp = softreq "lxp"
+	local lxp, err = softreq "lxp"
 
 	if not lxp then
 		missingdep("luaexpat", {
-				["Debian/Ubuntu"] = "sudo apt-get install lua-expat";
-				["luarocks"] = "luarocks install luaexpat";
-				["Source"] = "http://matthewwild.co.uk/projects/luaexpat/";
-			});
+				{ "Debian/Ubuntu", "sudo apt install lua-expat" };
+				{ "luarocks", "luarocks install luaexpat" };
+				{ "Source", "http://matthewwild.co.uk/projects/luaexpat/" };
+			}, nil, err);
 		fatal = true;
 	end
 
-	local socket = softreq "socket"
+	local socket, err = softreq "socket"
 
 	if not socket then
 		missingdep("luasocket", {
-				["Debian/Ubuntu"] = "sudo apt-get install lua-socket";
-				["luarocks"] = "luarocks install luasocket";
-				["Source"] = "http://www.tecgraf.puc-rio.br/~diego/professional/luasocket/";
-			});
+				{ "Debian/Ubuntu", "sudo apt install lua-socket" };
+				{ "luarocks", "luarocks install luasocket" };
+				{ "Source", "http://www.tecgraf.puc-rio.br/~diego/professional/luasocket/" };
+			}, nil, err);
 		fatal = true;
 	elseif not socket.tcp4 then
 		-- COMPAT LuaSocket before being IP-version agnostic
@@ -73,39 +71,53 @@
 	local lfs, err = softreq "lfs"
 	if not lfs then
 		missingdep("luafilesystem", {
-			["luarocks"] = "luarocks install luafilesystem";
-			["Debian/Ubuntu"] = "sudo apt-get install lua-filesystem";
-			["Source"] = "http://www.keplerproject.org/luafilesystem/";
-		});
+			{ "luarocks", "luarocks install luafilesystem" };
+			{ "Debian/Ubuntu", "sudo apt install lua-filesystem" };
+			{ "Source", "http://www.keplerproject.org/luafilesystem/" };
+		}, nil, err);
 		fatal = true;
 	end
 
-	local ssl = softreq "ssl"
+	local ssl, err = softreq "ssl"
 
 	if not ssl then
 		missingdep("LuaSec", {
-				["Debian/Ubuntu"] = "sudo apt-get install lua-sec";
-				["luarocks"] = "luarocks install luasec";
-				["Source"] = "https://github.com/brunoos/luasec";
-			}, "SSL/TLS support will not be available");
+				{ "Debian/Ubuntu", "sudo apt install lua-sec" };
+				{ "luarocks", "luarocks install luasec" };
+				{ "Source", "https://github.com/brunoos/luasec" };
+			}, nil, err);
 	end
 
-	local bit = softreq"bit" or softreq"bit32";
+	local bit, err = softreq"util.bitcompat";
 
 	if not bit then
 		missingdep("lua-bitops", {
-			["Debian/Ubuntu"] = "sudo apt-get install lua-bitop";
-			["luarocks"] = "luarocks install luabitop";
-			["Source"] = "http://bitop.luajit.org/";
-		}, "WebSocket support will not be available");
+			{ "Debian/Ubuntu", "sudo apt install lua-bitop" };
+			{ "luarocks", "luarocks install luabitop" };
+			{ "Source", "http://bitop.luajit.org/" };
+		}, "WebSocket support will not be available", err);
+	end
+
+	local unbound, err = softreq"lunbound"; -- luacheck: ignore 211/err
+	if not unbound then
+		missingdep("lua-unbound", {
+				{ "Debian/Ubuntu", "sudo apt install lua-unbound" };
+				{ "luarocks", "luarocks install luaunbound" };
+				{ "Source", "https://www.zash.se/luaunbound.html" };
+			}, "Old DNS resolver library will be used", err);
+	else
+		package.preload["net.adns"] = function ()
+			local ub = require "net.unbound";
+			return ub;
+		end
 	end
 
 	local encodings, err = softreq "util.encodings"
 	if not encodings then
 		if err:match("module '[^']*' not found") then
 			missingdep("util.encodings", {
-				["Windows"] = "Make sure you have encodings.dll from the Prosody distribution in util/";
-				["GNU/Linux"] = "Run './configure' and 'make' in the Prosody source directory to build util/encodings.so";
+				{ "Windows", "Make sure you have encodings.dll from the Prosody distribution in util/" };
+				{ "GNU/Linux", "Run './configure' and 'make' in the Prosody source directory to build util/encodings.so" };
 			});
 		else
 			print "***********************************"
@@ -122,8 +134,8 @@
 	if not hashes then
 		if err:match("module '[^']*' not found") then
 			missingdep("util.hashes", {
-				["Windows"] = "Make sure you have hashes.dll from the Prosody distribution in util/";
-				["GNU/Linux"] = "Run './configure' and 'make' in the Prosody source directory to build util/hashes.so";
+				{ "Windows", "Make sure you have hashes.dll from the Prosody distribution in util/" };
+				{ "GNU/Linux", "Run './configure' and 'make' in the Prosody source directory to build util/hashes.so" };
 			});
 		else
 			print "***********************************"
@@ -140,8 +152,10 @@
 end
 
 local function log_warnings()
-	if _VERSION > "Lua 5.2" then
+	if _VERSION > "Lua 5.4" then
 		prosody.log("warn", "Support for %s is experimental, please report any issues", _VERSION);
+	elseif _VERSION < "Lua 5.2" then
+		prosody.log("warn", "%s has several issues and support is being phased out, consider upgrading", _VERSION);
 	end
 	local ssl = softreq"ssl";
 	if ssl then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/dns.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,242 @@
+-- libunbound based net.adns replacement for Prosody IM
+-- Copyright (C) 2012-2015 Kim Alvefur
+-- Copyright (C) 2012 Waqas Hussain
+--
+-- This file is MIT licensed.
+
+local setmetatable = setmetatable;
+local table = table;
+local t_concat = table.concat;
+local t_insert = table.insert;
+local s_byte = string.byte;
+local s_format = string.format;
+local s_sub = string.sub;
+
+local iana_data = require "util.dnsregistry";
+local tohex = require "util.hex".encode;
+local inet_ntop = require "util.net".ntop;
+
+-- Simplified versions of Waqas DNS parsers
+-- Only the per RR parsers are needed and only feed a single RR
+
+local parsers = {};
+
+-- No support for pointers, but libunbound appears to take care of that.
+local function readDnsName(packet, pos)
+	if s_byte(packet, pos) == 0 then return ".", pos+1; end
+	local pack_len, r, len = #packet, {};
+	pos = pos or 1;
+	repeat
+		len = s_byte(packet, pos) or 0;
+		t_insert(r, s_sub(packet, pos + 1, pos + len));
+		pos = pos + len + 1;
+	until len == 0 or pos >= pack_len;
+	return t_concat(r, "."), pos;
+end
+
+-- These are just simple names.
+parsers.CNAME = readDnsName;
+parsers.NS = readDnsName
+parsers.PTR = readDnsName;
+
+local soa_mt = {
+	__tostring = function(rr)
+		return s_format("%s %s %d %d %d %d %d", rr.mname, rr.rname, rr.serial, rr.refresh, rr.retry, rr.expire, rr.minimum);
+	end;
+};
+function parsers.SOA(packet)
+	local mname, rname, offset;
+
+	mname, offset = readDnsName(packet, 1);
+	rname, offset = readDnsName(packet, offset);
+
+	-- Extract all the bytes of these fields in one call
+	local
+		s1, s2, s3, s4, -- serial
+		r1, r2, r3, r4, -- refresh
+		t1, t2, t3, t4, -- retry
+		e1, e2, e3, e4, -- expire
+		m1, m2, m3, m4  -- minimum
+			= s_byte(packet, offset, offset + 19);
+
+	return setmetatable({
+		mname = mname;
+		rname = rname;
+		serial  = s1*0x1000000 + s2*0x10000 + s3*0x100 + s4;
+		refresh = r1*0x1000000 + r2*0x10000 + r3*0x100 + r4;
+		retry   = t1*0x1000000 + t2*0x10000 + t3*0x100 + t4;
+		expire  = e1*0x1000000 + e2*0x10000 + e3*0x100 + e4;
+		minimum = m1*0x1000000 + m2*0x10000 + m3*0x100 + m4;
+	}, soa_mt);
+end
+
+parsers.A = inet_ntop;
+parsers.AAAA = inet_ntop;
+
+local mx_mt = {
+	__tostring = function(rr)
+		return s_format("%d %s", rr.pref, rr.mx)
+	end
+};
+function parsers.MX(packet)
+	local name = readDnsName(packet, 3);
+	local b1,b2 = s_byte(packet, 1, 2);
+	return setmetatable({
+		pref = b1*256+b2;
+		mx = name;
+	}, mx_mt);
+end
+
+local srv_mt = {
+	__tostring = function(rr)
+		return s_format("%d %d %d %s", rr.priority, rr.weight, rr.port, rr.target);
+	end
+};
+function parsers.SRV(packet)
+	local name = readDnsName(packet, 7);
+	local b1, b2, b3, b4, b5, b6 = s_byte(packet, 1, 6);
+	return setmetatable({
+		priority = b1*256+b2;
+		weight   = b3*256+b4;
+		port     = b5*256+b6;
+		target   = name;
+	}, srv_mt);
+end
+
+local txt_mt = { __tostring = t_concat };
+function parsers.TXT(packet)
+	local pack_len = #packet;
+	local r, pos, len = {}, 1;
+	repeat
+		len = s_byte(packet, pos) or 0;
+		t_insert(r, s_sub(packet, pos + 1, pos + len));
+		pos = pos + len + 1;
+	until pos >= pack_len;
+	return setmetatable(r, txt_mt);
+end
+
+parsers.SPF = parsers.TXT;
+
+-- Acronyms from RFC 7218
+local tlsa_usages = {
+	[0] = "PKIX-CA";
+	[1] = "PKIX-EE";
+	[2] = "DANE-TA";
+	[3] = "DANE-EE";
+	[255] = "PrivCert";
+};
+local tlsa_selectors = {
+	[0] = "Cert",
+	[1] = "SPKI",
+	[255] = "PrivSel",
+};
+local tlsa_match_types = {
+	[0] = "Full",
+	[1] = "SHA2-256",
+	[2] = "SHA2-512",
+	[255] = "PrivMatch",
+};
+local tlsa_mt = {
+	__tostring = function(rr)
+		return s_format("%s %s %s %s",
+			tlsa_usages[rr.use] or rr.use,
+			tlsa_selectors[rr.select] or rr.select,
+			tlsa_match_types[rr.match] or rr.match,
+			tohex(rr.data));
+	end;
+	__index = {
+		getUsage = function(rr) return tlsa_usages[rr.use] end;
+		getSelector = function(rr) return tlsa_selectors[rr.select] end;
+		getMatchType = function(rr) return tlsa_match_types[rr.match] end;
+	}
+};
+function parsers.TLSA(packet)
+	local use, select, match = s_byte(packet, 1,3);
+	return setmetatable({
+		use = use;
+		select = select;
+		match = match;
+		data = s_sub(packet, 4);
+	}, tlsa_mt);
+end
+
+local svcb_params = {"alpn"; "no-default-alpn"; "port"; "ipv4hint"; "ech"; "ipv6hint"};
+setmetatable(svcb_params, {__index = function(_, n) return "key" .. tostring(n); end});
+
+local svcb_mt = {
+	__tostring = function (rr)
+		local kv = {};
+		for i = 1, #rr.fields do
+			t_insert(kv, s_format("%s=%q", svcb_params[rr.fields[i].key], tostring(rr.fields[i].value)));
+			-- FIXME the =value part may be omitted when the value is "empty"
+		end
+		return s_format("%d %s %s", rr.prio, rr.name, t_concat(kv, " "));
+	end;
+};
+local svbc_ip_mt = {__tostring = function(ip) return t_concat(ip, ", "); end}
+
+function parsers.SVCB(packet)
+	local prio_h, prio_l = packet:byte(1,2);
+	local prio = prio_h*256+prio_l;
+	local name, pos = readDnsName(packet, 3);
+	local fields = {};
+	while #packet > pos do
+		local key_h, key_l = packet:byte(pos+0,pos+1);
+		local len_h, len_l = packet:byte(pos+2,pos+3);
+		local key = key_h*256+key_l;
+		local len = len_h*256+len_l;
+		local value = packet:sub(pos+4,pos+4-1+len)
+		if key == 1 then
+			value = setmetatable(parsers.TXT(value), svbc_ip_mt);
+		elseif key == 3 then
+			local port_h, port_l = value:byte(1,2);
+			local port = port_h*256+port_l;
+			value = port;
+		elseif key == 4 then
+			local ip = {};
+			for i = 1, #value, 4 do
+				t_insert(ip, parsers.A(value:sub(i, i+3)));
+			end
+			value = setmetatable(ip, svbc_ip_mt);
+		elseif key == 6 then
+			local ip = {};
+			for i = 1, #value, 16 do
+				t_insert(ip, parsers.AAAA(value:sub(i, i+15)));
+			end
+			value = setmetatable(ip, svbc_ip_mt);
+		end
+		t_insert(fields, { key = key, value = value, len = len });
+		pos = pos+len+4;
+	end
+	return setmetatable({
+			prio = prio, name = name, fields = fields,
+		}, svcb_mt);
+end
+
+parsers.HTTPS = parsers.SVCB;
+
+local params = {
+	TLSA = {
+		use = tlsa_usages;
+		select = tlsa_selectors;
+		match = tlsa_match_types;
+	};
+};
+
+local fallback_mt = {
+	__tostring = function(rr)
+		return s_format([[\# %d %s]], #rr.raw, tohex(rr.raw));
+	end;
+};
+local function fallback_parser(packet)
+	return setmetatable({ raw = packet },fallback_mt);
+end
+setmetatable(parsers, { __index = function() return fallback_parser end });
+
+return {
+	parsers = parsers;
+	classes = iana_data.classes;
+	types = iana_data.types;
+	errors = iana_data.errors;
+	params = params;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/dnsregistry.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,122 @@
+-- Source: https://www.iana.org/assignments/dns-parameters/dns-parameters.xml
+-- Generated on 2022-02-02
+return {
+	classes = {
+		["IN"] = 1; [1] = "IN";
+		["CH"] = 3; [3] = "CH";
+		["HS"] = 4; [4] = "HS";
+		["ANY"] = 255; [255] = "ANY";
+	};
+	types = {
+		["A"] = 1; [1] = "A";
+		["NS"] = 2; [2] = "NS";
+		["MD"] = 3; [3] = "MD";
+		["MF"] = 4; [4] = "MF";
+		["CNAME"] = 5; [5] = "CNAME";
+		["SOA"] = 6; [6] = "SOA";
+		["MB"] = 7; [7] = "MB";
+		["MG"] = 8; [8] = "MG";
+		["MR"] = 9; [9] = "MR";
+		["NULL"] = 10; [10] = "NULL";
+		["WKS"] = 11; [11] = "WKS";
+		["PTR"] = 12; [12] = "PTR";
+		["HINFO"] = 13; [13] = "HINFO";
+		["MINFO"] = 14; [14] = "MINFO";
+		["MX"] = 15; [15] = "MX";
+		["TXT"] = 16; [16] = "TXT";
+		["RP"] = 17; [17] = "RP";
+		["AFSDB"] = 18; [18] = "AFSDB";
+		["X25"] = 19; [19] = "X25";
+		["ISDN"] = 20; [20] = "ISDN";
+		["RT"] = 21; [21] = "RT";
+		["NSAP"] = 22; [22] = "NSAP";
+		["NSAP-PTR"] = 23; [23] = "NSAP-PTR";
+		["SIG"] = 24; [24] = "SIG";
+		["KEY"] = 25; [25] = "KEY";
+		["PX"] = 26; [26] = "PX";
+		["GPOS"] = 27; [27] = "GPOS";
+		["AAAA"] = 28; [28] = "AAAA";
+		["LOC"] = 29; [29] = "LOC";
+		["NXT"] = 30; [30] = "NXT";
+		["EID"] = 31; [31] = "EID";
+		["NIMLOC"] = 32; [32] = "NIMLOC";
+		["SRV"] = 33; [33] = "SRV";
+		["ATMA"] = 34; [34] = "ATMA";
+		["NAPTR"] = 35; [35] = "NAPTR";
+		["KX"] = 36; [36] = "KX";
+		["CERT"] = 37; [37] = "CERT";
+		["A6"] = 38; [38] = "A6";
+		["DNAME"] = 39; [39] = "DNAME";
+		["SINK"] = 40; [40] = "SINK";
+		["OPT"] = 41; [41] = "OPT";
+		["APL"] = 42; [42] = "APL";
+		["DS"] = 43; [43] = "DS";
+		["SSHFP"] = 44; [44] = "SSHFP";
+		["IPSECKEY"] = 45; [45] = "IPSECKEY";
+		["RRSIG"] = 46; [46] = "RRSIG";
+		["NSEC"] = 47; [47] = "NSEC";
+		["DNSKEY"] = 48; [48] = "DNSKEY";
+		["DHCID"] = 49; [49] = "DHCID";
+		["NSEC3"] = 50; [50] = "NSEC3";
+		["NSEC3PARAM"] = 51; [51] = "NSEC3PARAM";
+		["TLSA"] = 52; [52] = "TLSA";
+		["SMIMEA"] = 53; [53] = "SMIMEA";
+		["Unassigned"] = 54; [54] = "Unassigned";
+		["HIP"] = 55; [55] = "HIP";
+		["NINFO"] = 56; [56] = "NINFO";
+		["RKEY"] = 57; [57] = "RKEY";
+		["TALINK"] = 58; [58] = "TALINK";
+		["CDS"] = 59; [59] = "CDS";
+		["CDNSKEY"] = 60; [60] = "CDNSKEY";
+		["OPENPGPKEY"] = 61; [61] = "OPENPGPKEY";
+		["CSYNC"] = 62; [62] = "CSYNC";
+		["ZONEMD"] = 63; [63] = "ZONEMD";
+		["SVCB"] = 64; [64] = "SVCB";
+		["HTTPS"] = 65; [65] = "HTTPS";
+		["SPF"] = 99; [99] = "SPF";
+		["NID"] = 104; [104] = "NID";
+		["L32"] = 105; [105] = "L32";
+		["L64"] = 106; [106] = "L64";
+		["LP"] = 107; [107] = "LP";
+		["EUI48"] = 108; [108] = "EUI48";
+		["EUI64"] = 109; [109] = "EUI64";
+		["TKEY"] = 249; [249] = "TKEY";
+		["TSIG"] = 250; [250] = "TSIG";
+		["IXFR"] = 251; [251] = "IXFR";
+		["AXFR"] = 252; [252] = "AXFR";
+		["MAILB"] = 253; [253] = "MAILB";
+		["MAILA"] = 254; [254] = "MAILA";
+		["*"] = 255; [255] = "*";
+		["URI"] = 256; [256] = "URI";
+		["CAA"] = 257; [257] = "CAA";
+		["AVC"] = 258; [258] = "AVC";
+		["DOA"] = 259; [259] = "DOA";
+		["AMTRELAY"] = 260; [260] = "AMTRELAY";
+		["TA"] = 32768; [32768] = "TA";
+		["DLV"] = 32769; [32769] = "DLV";
+	};
+	errors = {
+		[0] = "NoError"; ["NoError"] = "No Error";
+		[1] = "FormErr"; ["FormErr"] = "Format Error";
+		[2] = "ServFail"; ["ServFail"] = "Server Failure";
+		[3] = "NXDomain"; ["NXDomain"] = "Non-Existent Domain";
+		[4] = "NotImp"; ["NotImp"] = "Not Implemented";
+		[5] = "Refused"; ["Refused"] = "Query Refused";
+		[6] = "YXDomain"; ["YXDomain"] = "Name Exists when it should not";
+		[7] = "YXRRSet"; ["YXRRSet"] = "RR Set Exists when it should not";
+		[8] = "NXRRSet"; ["NXRRSet"] = "RR Set that should exist does not";
+		[9] = "NotAuth"; ["NotAuth"] = "Server Not Authoritative for zone";
+		-- [9] = "NotAuth"; ["NotAuth"] = "Not Authorized";
+		[10] = "NotZone"; ["NotZone"] = "Name not contained in zone";
+		[11] = "DSOTYPENI"; ["DSOTYPENI"] = "DSO-TYPE Not Implemented";
+		[16] = "BADVERS"; ["BADVERS"] = "Bad OPT Version";
+		-- [16] = "BADSIG"; ["BADSIG"] = "TSIG Signature Failure";
+		[17] = "BADKEY"; ["BADKEY"] = "Key not recognized";
+		[18] = "BADTIME"; ["BADTIME"] = "Signature out of time window";
+		[19] = "BADMODE"; ["BADMODE"] = "Bad TKEY Mode";
+		[20] = "BADNAME"; ["BADNAME"] = "Duplicate key name";
+		[21] = "BADALG"; ["BADALG"] = "Algorithm not supported";
+		[22] = "BADTRUNC"; ["BADTRUNC"] = "Bad Truncation";
+		[23] = "BADCOOKIE"; ["BADCOOKIE"] = "Bad/missing Server Cookie";
+	};
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/error.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,170 @@
+local id = require "util.id";
+
+local util_debug; -- only imported on-demand
+
+-- Library configuration (see configure())
+local auto_inject_traceback = false;
+
+local error_mt = { __name = "error" };
+
+function error_mt:__tostring()
+	return ("error<%s:%s:%s>"):format(self.type, self.condition, self.text or "");
+end
+
+local function is_error(e)
+	return getmetatable(e) == error_mt;
+end
+
+local function configure(opt)
+	if opt.auto_inject_traceback ~= nil then
+		auto_inject_traceback = opt.auto_inject_traceback;
+		if auto_inject_traceback then
+			util_debug = require "util.debug";
+		end
+	end
+end
+
+-- Do we want any more well-known fields?
+-- Or could we just copy all fields from `e`?
+-- Sometimes you want variable details in the `text`, how to handle that?
+-- Translations?
+-- Should the `type` be restricted to the stanza error types or free-form?
+-- What to set `type` to for stream errors or SASL errors? Those don't have a 'type' attr.
+
+local function new(e, context, registry, source)
+	if is_error(e) then return e; end
+	local template = registry and registry[e];
+	if not template then
+		if type(e) == "table" then
+			template = {
+				code = e.code;
+				type = e.type;
+				condition = e.condition;
+				text = e.text;
+				extra = e.extra;
+			};
+		else
+			template = {};
+		end
+	end
+	context = context or {};
+
+	if auto_inject_traceback then
+		context.traceback = util_debug.get_traceback_table(nil, 2);
+	end
+
+	local error_instance = setmetatable({
+		instance_id = id.short();
+
+		type = template.type or "cancel";
+		condition = template.condition or "undefined-condition";
+		text = template.text;
+		code = template.code;
+		extra = template.extra;
+
+		context = context;
+		source = source;
+	}, error_mt);
+
+	return error_instance;
+end
+
+-- compact --> normal form
+local function expand_registry(namespace, registry)
+	local mapped = {}
+	for err,template in pairs(registry) do
+		local e = {
+			type = template[1];
+			condition = template[2];
+			text = template[3];
+		};
+		if namespace and template[4] then
+			e.extra = { namespace = namespace, condition = template[4] };
+		end
+		mapped[err] = e;
+	end
+	return mapped;
+end
+
+local function init(source, namespace, registry)
+	if type(namespace) == "table" then
+		-- registry can be given as second argument if namespace is not used
+		registry, namespace = namespace, nil;
+	end
+	local _, protoerr = next(registry, nil);
+	if protoerr and type(next(protoerr)) == "number" then
+		registry = expand_registry(namespace, registry);
+	end
+
+	local function wrap(e, context)
+		if is_error(e) then
+			return e;
+		end
+		local err = new(registry[e] or {
+			type = "cancel", condition = "undefined-condition"
+		}, context, registry, source);
+		err.context.wrapped_error = e;
+		return err;
+	end
+
+	return {
+		source = source;
+		registry = registry;
+		new = function (e, context)
+			return new(e, context, registry, source);
+		end;
+		coerce = function (ok, err, ...)
+			if ok then
+				return ok, err, ...;
+			end
+			return nil, wrap(err);
+		end;
+		wrap = wrap;
+		is_error = is_error;
+	};
+end
+
+local function coerce(ok, err, ...)
+	if ok or is_error(err) then
+		return ok, err, ...;
+	end
+
+	local new_err = new({
+		type = "cancel", condition = "undefined-condition"
+	}, { wrapped_error = err });
+
+	return ok, new_err, ...;
+end
+
+local function from_stanza(stanza, context, source)
+	local error_type, condition, text, extra_tag = stanza:get_error();
+	local error_tag = stanza:get_child("error");
+	context = context or {};
+	context.stanza = stanza;
+	context.by = error_tag.attr.by or stanza.attr.from;
+
+	local uri;
+	if condition == "gone" or condition == "redirect" then
+		uri = error_tag:get_child_text(condition, "urn:ietf:params:xml:ns:xmpp-stanzas");
+	end
+
+	return new({
+		type = error_type or "cancel";
+		condition = condition or "undefined-condition";
+		text = text;
+		extra = (extra_tag or uri) and {
+			uri = uri;
+			tag = extra_tag;
+		} or nil;
+	}, context, nil, source);
+end
+
+return {
+	new = new;
+	init = init;
+	coerce = coerce;
+	is_error = is_error;
+	is_err = is_error; -- COMPAT w/ older 0.12 trunk
+	from_stanza = from_stanza;
+	configure = configure;
+}
--- a/util/events.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/events.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -26,6 +26,8 @@
 	local wrappers = {};
 	-- Event map: event_map[handler_function] = priority_number
 	local event_map = {};
+	-- Debug hook, if any
+	local active_debug_hook = nil;
 	-- Called on-demand to build handlers entries
 	local function _rebuild_index(self, event)
 		local _handlers = event_map[event];
@@ -74,11 +76,16 @@
 	end;
 	local function _fire_event(event_name, event_data)
 		local h = handlers[event_name];
-		if h then
+		if h and not active_debug_hook then
 			for i=1,#h do
 				local ret = h[i](event_data);
 				if ret ~= nil then return ret; end
 			end
+		elseif h and active_debug_hook then
+			for i=1,#h do
+				local ret = active_debug_hook(h[i], event_name, event_data);
+				if ret ~= nil then return ret; end
+			end
 		end
 	end;
 	local function fire_event(event_name, event_data)
@@ -140,6 +147,13 @@
 			end
 		end
 	end
+
+	local function set_debug_hook(new_hook)
+		local old_hook = active_debug_hook;
+		active_debug_hook = new_hook;
+		return old_hook;
+	end
+
 	return {
 		add_handler = add_handler;
 		remove_handler = remove_handler;
@@ -150,8 +164,12 @@
 			add_handler = add_wrapper;
 			remove_handler = remove_wrapper;
 		};
+
 		add_wrapper = add_wrapper;
 		remove_wrapper = remove_wrapper;
+
+		set_debug_hook = set_debug_hook;
+
 		fire_event = fire_event;
 		_handlers = handlers;
 		_event_map = event_map;
--- a/util/format.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/format.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,14 +1,45 @@
 --
--- A string.format wrapper that gracefully handles invalid arguments
+-- A string.format wrapper that gracefully handles invalid arguments since
+-- certain format string and argument combinations may cause errors or other
+-- issues like log spoofing
 --
+-- Provides some protection from e.g. CAPEC-135, CWE-117, CWE-134, CWE-93
 
 local tostring = tostring;
-local select = select;
 local unpack = table.unpack or unpack; -- luacheck: ignore 113/unpack
+local pack = require "util.table".pack; -- TODO table.pack in 5.2+
+local valid_utf8 = require "util.encodings".utf8.valid;
 local type = type;
+local dump = require "util.serialization".new("debug");
+local num_type = math.type or function (n)
+	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
+end
+
+-- In Lua 5.3+ these formats throw an error if given a float
+local expects_integer = { c = true, d = true, i = true, o = true, u = true, X = true, x = true, };
+-- In Lua 5.2 these throw an error given a negative number
+local expects_positive = { o = true; u = true; x = true; X = true };
+-- Printable Unicode replacements for control characters
+local control_symbols = {
+	-- 0x00 .. 0x1F --> U+2400 .. U+241F, 0x7F --> U+2421
+	["\000"] = "\226\144\128", ["\001"] = "\226\144\129", ["\002"] = "\226\144\130",
+	["\003"] = "\226\144\131", ["\004"] = "\226\144\132", ["\005"] = "\226\144\133",
+	["\006"] = "\226\144\134", ["\007"] = "\226\144\135", ["\008"] = "\226\144\136",
+	["\009"] = "\226\144\137", ["\010"] = "\226\144\138", ["\011"] = "\226\144\139",
+	["\012"] = "\226\144\140", ["\013"] = "\226\144\141", ["\014"] = "\226\144\142",
+	["\015"] = "\226\144\143", ["\016"] = "\226\144\144", ["\017"] = "\226\144\145",
+	["\018"] = "\226\144\146", ["\019"] = "\226\144\147", ["\020"] = "\226\144\148",
+	["\021"] = "\226\144\149", ["\022"] = "\226\144\150", ["\023"] = "\226\144\151",
+	["\024"] = "\226\144\152", ["\025"] = "\226\144\153", ["\026"] = "\226\144\154",
+	["\027"] = "\226\144\155", ["\028"] = "\226\144\156", ["\029"] = "\226\144\157",
+	["\030"] = "\226\144\158", ["\031"] = "\226\144\159", ["\127"] = "\226\144\161",
+};
+local supports_p = pcall(string.format, "%p", ""); -- >= Lua 5.4
+local supports_a = pcall(string.format, "%a", 0.0); -- > Lua 5.1
 
 local function format(formatstring, ...)
-	local args, args_length = { ... }, select('#', ...);
+	local args = pack(...);
+	local args_length = args.n;
 
 	-- format specifier spec:
 	-- 1. Start: '%%'
@@ -20,28 +51,83 @@
 	-- The options c, d, E, e, f, g, G, i, o, u, X, and x all expect a number as argument, whereas q and s expect a string.
 	-- This function does not accept string values containing embedded zeros, except as arguments to the q option.
 	-- a and A are only in Lua 5.2+
+	-- Lua 5.4 adds a p format that produces a pointer
 
 
 	-- process each format specifier
 	local i = 0;
-	formatstring = formatstring:gsub("%%[^cdiouxXaAeEfgGqs%%]*[cdiouxXaAeEfgGqs%%]", function(spec)
-		if spec ~= "%%" then
-			i = i + 1;
-			local arg = args[i];
-			if arg == nil then -- special handling for nil
-				arg = "<nil>"
-				args[i] = "<nil>";
-			end
+	formatstring = formatstring:gsub("%%[^cdiouxXaAeEfgGpqs%%]*[cdiouxXaAeEfgGpqs%%]", function(spec)
+		if spec == "%%" then return end
+		i = i + 1;
+		local arg = args[i];
+
+		if arg == nil then
+			args[i] = "nil";
+			return "(%s)";
+		end
+
+		local option = spec:sub(-1);
+		local t = type(arg);
 
-			local option = spec:sub(-1);
-			if option == "q" or option == "s" then -- arg should be string
+		if option == "s" and t == "string" and not arg:find("[%z\1-\31\128-\255]") then
+			-- No UTF-8 or control characters, assumed to be the common case.
+			return
+		elseif t == "number" then
+			if option == "g" or (option == "d" and num_type(arg) == "integer") then return end
+		elseif option == "s" and t ~= "string" then
+			arg = tostring(arg);
+			t = "string";
+		end
+
+		if option ~= "s" and option ~= "q" and option ~= "p" then
+			-- all other options expect numbers
+			if t ~= "number" then
+				-- arg isn't number as expected?
+				arg = tostring(arg);
+				option = "s";
+				spec = "[%s]";
+				t = "string";
+			elseif expects_integer[option] and num_type(arg) ~= "integer" then
 				args[i] = tostring(arg);
-			elseif type(arg) ~= "number" then -- arg isn't number as expected?
+				return "[%s]";
+			elseif expects_positive[option] and arg < 0 then
 				args[i] = tostring(arg);
-				spec = "[%s]";
+				return "[%s]";
+			elseif (option == "a" or option == "A") and not supports_a then
+				return "%x";
+			else
+				return -- acceptable number
 			end
 		end
-		return spec;
+
+
+		if option == "p" and not supports_p then
+			arg = tostring(arg);
+			option = "s";
+			spec = "[%s]";
+			t = "string";
+		end
+
+		if t == "string" and option ~= "p" then
+			if not valid_utf8(arg) then
+				option = "q";
+			elseif option ~= "q" then -- gets fully escaped in the next block
+				-- Prevent funny things with ASCII control characters and ANSI escape codes (CWE-117)
+				-- Also ensure embedded newlines can't look like another log line (CWE-93)
+				args[i] = arg:gsub("[%z\1-\8\11-\31\127]", control_symbols):gsub("\n\t?", "\n\t");
+				return spec;
+			end
+		end
+
+		if option == "q" then
+			args[i] = dump(arg);
+			return "%s";
+		end
+
+		if option == "p" and (t == "boolean" or t == "number") then
+			args[i] = tostring(arg);
+			return "[%s]";
+		end
 	end);
 
 	-- process extra args
@@ -49,9 +135,9 @@
 		i = i + 1;
 		local arg = args[i];
 		if arg == nil then
-			args[i] = "<nil>";
+			args[i] = "(nil)";
 		else
-			args[i] = tostring(arg);
+			args[i] = tostring(arg):gsub("[%z\1-\8\11-\31\127]", control_symbols):gsub("\n\t?", "\n\t");
 		end
 		formatstring = formatstring .. " [%s]"
 	end
--- a/util/gc.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/gc.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -5,7 +5,7 @@
 	generational = set.new { "mode", "minor_threshold", "major_threshold" };
 };
 
-if _VERSION ~= "5.4" then
+if _VERSION ~= "Lua 5.4" then
 	known_options.generational = nil;
 	known_options.incremental:remove("step_size");
 end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/hashring.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,88 @@
+local function generate_ring(nodes, num_replicas, hash)
+	local new_ring = {};
+	for _, node_name in ipairs(nodes) do
+		for replica = 1, num_replicas do
+			local replica_hash = hash(node_name..":"..replica);
+			new_ring[replica_hash] = node_name;
+			table.insert(new_ring, replica_hash);
+		end
+	end
+	table.sort(new_ring);
+	return new_ring;
+end
+
+local hashring_methods = {};
+local hashring_mt = {
+	__index = function (self, k)
+		-- Automatically build self.ring if it's missing
+		if k == "ring" then
+			local ring = generate_ring(self.nodes, self.num_replicas, self.hash);
+			rawset(self, "ring", ring);
+			return ring;
+		end
+		return rawget(hashring_methods, k);
+	end
+};
+
+local function new(num_replicas, hash_function)
+	return setmetatable({ nodes = {}, num_replicas = num_replicas, hash = hash_function }, hashring_mt);
+end;
+
+function hashring_methods:add_node(name)
+	self.ring = nil;
+	self.nodes[name] = true;
+	table.insert(self.nodes, name);
+	return true;
+end
+
+function hashring_methods:add_nodes(nodes)
+	self.ring = nil;
+	for _, node_name in ipairs(nodes) do
+		if not self.nodes[node_name] then
+			self.nodes[node_name] = true;
+			table.insert(self.nodes, node_name);
+		end
+	end
+	return true;
+end
+
+function hashring_methods:remove_node(node_name)
+	self.ring = nil;
+	if self.nodes[node_name] then
+		for i, stored_node_name in ipairs(self.nodes) do
+			if node_name == stored_node_name then
+				self.nodes[node_name] = nil;
+				table.remove(self.nodes, i);
+				return true;
+			end
+		end
+	end
+	return false;
+end
+
+function hashring_methods:remove_nodes(nodes)
+	self.ring = nil;
+	for _, node_name in ipairs(nodes) do
+		self:remove_node(node_name);
+	end
+end
+
+function hashring_methods:clone()
+	local clone_hashring = new(self.num_replicas, self.hash);
+	clone_hashring:add_nodes(self.nodes);
+	return clone_hashring;
+end
+
+function hashring_methods:get_node(key)
+	local key_hash = self.hash(key);
+	for _, replica_hash in ipairs(self.ring) do
+		if key_hash < replica_hash then
+			return self.ring[replica_hash];
+		end
+	end
+	return self.ring[self.ring[1]];
+end
+
+return {
+	new = new;
+}
--- a/util/helpers.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/helpers.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -23,12 +23,27 @@
 		logger("debug", "%s firing event: %s", name, event);
 		return f(event, ...);
 	end
+
+	local function event_handler_hook(handler, event_name, event_data)
+		logger("debug", "calling handler for %s: %s", event_name, handler);
+		local ok, ret = pcall(handler, event_data);
+		if not ok then
+			logger("error", "error in event handler %s: %s", handler, ret);
+			error(ret);
+		end
+		if ret ~= nil then
+			logger("debug", "event chain ended for %s by %s with result: %s", event_name, handler, ret);
+		end
+		return ret;
+	end
+	events.set_debug_hook(event_handler_hook);
 	events[events.fire_event] = f;
 	return events;
 end
 
 local function revert_log_events(events)
 	events.fire_event, events[events.fire_event] = events[events.fire_event], nil; -- :))
+	events.set_debug_hook(nil);
 end
 
 local function log_host_events(host)
--- a/util/hex.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/hex.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -23,4 +23,8 @@
 	return (s_gsub(s_lower(s), "%X*(%x%x)%X*", hex_to_char));
 end
 
-return { to = to, from = from }
+return {
+	encode = to, decode = from;
+	-- COMPAT w/pre-0.12:
+	to = to, from = from;
+};
--- a/util/hmac.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/hmac.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,6 +10,9 @@
 
 local hashes = require "util.hashes"
 
-return { md5 = hashes.hmac_md5,
-	 sha1 = hashes.hmac_sha1,
-	 sha256 = hashes.hmac_sha256 };
+return {
+	md5 = hashes.hmac_md5,
+	sha1 = hashes.hmac_sha1,
+	sha256 = hashes.hmac_sha256,
+	sha512 = hashes.hmac_sha512,
+};
--- a/util/http.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/http.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -6,24 +6,26 @@
 --
 
 local format, char = string.format, string.char;
-local pairs, ipairs, tonumber = pairs, ipairs, tonumber;
+local pairs, ipairs = pairs, ipairs;
 local t_insert, t_concat = table.insert, table.concat;
 
+local url_codes = {};
+for i = 0, 255 do
+	local c = char(i);
+	local u = format("%%%02x", i);
+	url_codes[c] = u;
+	url_codes[u] = c;
+	url_codes[u:upper()] = c;
+end
 local function urlencode(s)
-	return s and (s:gsub("[^a-zA-Z0-9.~_-]", function (c) return format("%%%02x", c:byte()); end));
+	return s and (s:gsub("[^a-zA-Z0-9.~_-]", url_codes));
 end
 local function urldecode(s)
-	return s and (s:gsub("%%(%x%x)", function (c) return char(tonumber(c,16)); end));
+	return s and (s:gsub("%%%x%x", url_codes));
 end
 
 local function _formencodepart(s)
-	return s and (s:gsub("%W", function (c)
-		if c ~= " " then
-			return format("%%%02x", c:byte());
-		else
-			return "+";
-		end
-	end));
+	return s and (urlencode(s):gsub("%%20", "+"));
 end
 
 local function formencode(form)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/human/io.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,192 @@
+local array = require "util.array";
+local utf8 = rawget(_G, "utf8") or require"util.encodings".utf8;
+local len = utf8.len or function(s)
+	local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", "");
+	return count;
+end;
+
+local function getchar(n)
+	local stty_ret = os.execute("stty raw -echo 2>/dev/null");
+	local ok, char;
+	if stty_ret == true or stty_ret == 0 then
+		ok, char = pcall(io.read, n or 1);
+		os.execute("stty sane");
+	else
+		ok, char = pcall(io.read, "*l");
+		if ok then
+			char = char:sub(1, n or 1);
+		end
+	end
+	if ok then
+		return char;
+	end
+end
+
+local function getline()
+	local ok, line = pcall(io.read, "*l");
+	if ok then
+		return line;
+	end
+end
+
+local function getpass()
+	local stty_ret, _, status_code = os.execute("stty -echo 2>/dev/null");
+	if status_code then -- COMPAT w/ Lua 5.1
+		stty_ret = status_code;
+	end
+	if stty_ret ~= 0 then
+		io.write("\027[08m"); -- ANSI 'hidden' text attribute
+	end
+	local ok, pass = pcall(io.read, "*l");
+	if stty_ret == 0 then
+		os.execute("stty sane");
+	else
+		io.write("\027[00m");
+	end
+	io.write("\n");
+	if ok then
+		return pass;
+	end
+end
+
+local function show_yesno(prompt)
+	io.write(prompt, " ");
+	local choice = getchar():lower();
+	io.write("\n");
+	if not choice:match("%a") then
+		choice = prompt:match("%[.-(%U).-%]$");
+		if not choice then return nil; end
+	end
+	return (choice == "y");
+end
+
+local function read_password()
+	local password;
+	while true do
+		io.write("Enter new password: ");
+		password = getpass();
+		if not password then
+			print("No password - cancelled");
+			return;
+		end
+		io.write("Retype new password: ");
+		if getpass() ~= password then
+			if not show_yesno [=[Passwords did not match, try again? [Y/n]]=] then
+				return;
+			end
+		else
+			break;
+		end
+	end
+	return password;
+end
+
+local function show_prompt(prompt)
+	io.write(prompt, " ");
+	local line = getline();
+	line = line and line:gsub("\n$","");
+	return (line and #line > 0) and line or nil;
+end
+
+local function printf(fmt, ...)
+	print(fmt:format(...));
+end
+
+local function padright(s, width)
+	return s..string.rep(" ", width-len(s));
+end
+
+local function padleft(s, width)
+	return string.rep(" ", width-len(s))..s;
+end
+
+local pat = "[%z\001-\127\194-\253][\128-\191]*";
+local function utf8_cut(s, pos)
+	return s:match("^"..pat:rep(pos)) or s;
+end
+
+if utf8.len and utf8.offset then
+	function utf8_cut(s, pos)
+		return s:sub(1, utf8.offset(s, pos+1)-1);
+	end
+end
+
+local function ellipsis(s, width)
+	if len(s) <= width then return s; end
+	if width == 1 then return "…"; end
+	return utf8_cut(s, width - 1) .. "…";
+end
+
+local function new_table(col_specs, max_width)
+	max_width = max_width or tonumber(os.getenv("COLUMNS")) or 80;
+	local separator = " | ";
+
+	local widths = {};
+	local total_width = max_width - #separator * (#col_specs-1);
+	local free_width = total_width;
+	-- Calculate width of fixed-size columns
+	for i = 1, #col_specs do
+		local width = col_specs[i].width or "0";
+		if not(type(width) == "string" and width:sub(-1) == "%") then
+			local title = col_specs[i].title;
+			width = math.max(tonumber(width), title and (#title+1) or 0);
+			widths[i] = width;
+			free_width = free_width - width;
+			if i > 1 then
+				free_width = free_width - #separator;
+			end
+		end
+	end
+	-- Calculate width of %-based columns
+	for i = 1, #col_specs do
+		if not widths[i] then
+			local pc_width = tonumber((col_specs[i].width:gsub("%%$", "")));
+			widths[i] = math.floor(free_width*(pc_width/100));
+		end
+	end
+
+	return function (row)
+		local titles;
+		if not row then
+			titles, row = true, array.pluck(col_specs, "title", "");
+		end
+		local output = {};
+		for i, column in ipairs(col_specs) do
+			local width = widths[i];
+			local v = row[not titles and column.key or i];
+			if not titles and column.mapper then
+				v = column.mapper(v, row);
+			end
+			if v == nil then
+				v = column.default or "";
+			else
+				v = tostring(v);
+			end
+			if len(v) < width then
+				if column.align == "right" then
+					v = padleft(v, width);
+				else
+					v = padright(v, width);
+				end
+			elseif len(v) > width then
+				v = ellipsis(v, width);
+			end
+			table.insert(output, v);
+		end
+		return table.concat(output, separator);
+	end;
+end
+
+return {
+	getchar = getchar;
+	getline = getline;
+	getpass = getpass;
+	show_yesno = show_yesno;
+	read_password = read_password;
+	show_prompt = show_prompt;
+	printf = printf;
+	padleft = padleft;
+	padright = padright;
+	ellipsis = ellipsis;
+	table = new_table;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/human/units.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,80 @@
+local math_abs = math.abs;
+local math_ceil = math.ceil;
+local math_floor = math.floor;
+local math_log = math.log;
+local math_max = math.max;
+local math_min = math.min;
+local unpack = table.unpack or unpack; --luacheck: ignore 113
+
+if math_log(10, 10) ~= 1 then
+	-- Lua 5.1 COMPAT
+	local log10 = math.log10;
+	function math_log(n, base)
+		return log10(n) / log10(base);
+	end
+end
+
+local large = {
+	"k", 1000,
+	"M", 1000000,
+	"G", 1000000000,
+	"T", 1000000000000,
+	"P", 1000000000000000,
+	"E", 1000000000000000000,
+	"Z", 1000000000000000000000,
+	"Y", 1000000000000000000000000,
+}
+local small = {
+	"m", 0.001,
+	"μ", 0.000001,
+	"n", 0.000000001,
+	"p", 0.000000000001,
+	"f", 0.000000000000001,
+	"a", 0.000000000000000001,
+	"z", 0.000000000000000000001,
+	"y", 0.000000000000000000000001,
+}
+
+local binary = {
+	"Ki", 2^10,
+	"Mi", 2^20,
+	"Gi", 2^30,
+	"Ti", 2^40,
+	"Pi", 2^50,
+	"Ei", 2^60,
+	"Zi", 2^70,
+	"Yi", 2^80,
+}
+
+local function adjusted_unit(n, b)
+	local round = math_floor;
+	local prefixes = large;
+	local logbase = 1000;
+	if b == 'b' then
+		prefixes = binary;
+		logbase = 1024;
+	elseif n < 1 then
+		prefixes = small;
+		round = math_ceil;
+	end
+	local m = math_max(0, math_min(8, round(math_abs(math_log(math_abs(n), logbase)))));
+	local prefix, multiplier = unpack(prefixes, m * 2-1, m*2);
+	return multiplier or 1, prefix;
+end
+
+-- n: number, the number to format
+-- unit: string, the base unit
+-- b: optional enum 'b', thousands base
+local function format(n, unit, b) --> string
+	local fmt = "%.3g %s%s";
+	if n == 0 then
+		return fmt:format(n, "", unit);
+	end
+	local multiplier, prefix = adjusted_unit(n, b);
+	return fmt:format(n / multiplier, prefix or "", unit);
+end
+
+return {
+	adjust = adjusted_unit;
+	format = format;
+};
--- a/util/id.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/id.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -17,9 +17,23 @@
 end
 
 return {
-	short =  function () return b64url_random(6); end;
-	medium = function () return b64url_random(12); end;
-	long =   function () return b64url_random(24); end;
+	-- sizes divisible by 3 fit nicely into base64 without padding==
+
+	-- for short lived things with low risk of collisions
+	tiny = function() return b64url_random(3); end;
+
+	-- close to 8 bytes, should be good enough for relatively short lived or uses
+	-- scoped by host or users, half the size of an uuid
+	short = function() return b64url_random(9); end;
+
+	-- more entropy than uuid at 2/3 the size
+	-- should be okay for globally scoped ids or security token
+	medium = function() return b64url_random(18); end;
+
+	-- as long as an uuid but MOAR entropy
+	long = function() return b64url_random(27); end;
+
+	-- pick your own adventure
 	custom = function (size)
 		return function () return b64url_random(size); end;
 	end;
--- a/util/import.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/import.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,7 +8,7 @@
 
 
 
-local unpack = table.unpack or unpack; --luacheck: ignore 113 143
+local unpack = table.unpack or unpack; --luacheck: ignore 113
 local t_insert = table.insert;
 function _G.import(module, ...)
 	local m = package.loaded[module] or require(module);
--- a/util/interpolation.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/interpolation.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -64,6 +64,9 @@
 			elseif opt == '&' then
 				if not value then return ""; end
 				return render(s_sub(block, e), values);
+			elseif opt == '~' then
+				if value then return ""; end
+				return render(s_sub(block, e), values);
 			elseif opt == '?' and not value then
 				return render(s_sub(block, e), values);
 			elseif value ~= nil then
--- a/util/ip.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/ip.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -19,8 +19,14 @@
 		return ret;
 	end,
 	__tostring = function (ip) return ip.addr; end,
-	__eq = function (ipA, ipB) return ipA.packed == ipB.packed; end
 };
+ip_mt.__eq = function (ipA, ipB)
+	if getmetatable(ipA) ~= ip_mt or getmetatable(ipB) ~= ip_mt then
+		-- Lua 5.3+ calls this if both operands are tables, even if metatables differ
+		return false;
+	end
+	return ipA.packed == ipB.packed;
+end
 
 local hex2bits = {
 	["0"] = "0000", ["1"] = "0001", ["2"] = "0010", ["3"] = "0011",
@@ -61,7 +67,7 @@
 end
 
 function ip_methods.bits(ip)
-	return hex.to(ip.packed):upper():gsub(".", hex2bits);
+	return hex.encode(ip.packed):upper():gsub(".", hex2bits);
 end
 
 function ip_methods.bits_full(ip)
--- a/util/iterators.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/iterators.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -11,9 +11,9 @@
 local it = {};
 
 local t_insert = table.insert;
-local select, next = select, next;
-local unpack = table.unpack or unpack; --luacheck: ignore 113 143
-local pack = table.pack or function (...) return { n = select("#", ...), ... }; end -- luacheck: ignore 143
+local next = next;
+local unpack = table.unpack or unpack; --luacheck: ignore 113
+local pack = table.pack or require "util.table".pack;
 local type = type;
 local table, setmetatable = table, setmetatable;
 
--- a/util/jid.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/jid.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -22,63 +22,67 @@
 	["@"] = "\\40"; ["\\"] = "\\5c";
 };
 local unescapes = {};
-for k,v in pairs(escapes) do unescapes[v] = k; end
+local backslash_escapes = {};
+for k,v in pairs(escapes) do
+	unescapes[v] = k;
+	backslash_escapes[v] = v:gsub("\\", escapes)
+end
 
 local _ENV = nil;
 -- luacheck: std none
 
 local function split(jid)
-	if not jid then return; end
+	if jid == nil then return; end
 	local node, nodepos = match(jid, "^([^@/]+)@()");
 	local host, hostpos = match(jid, "^([^@/]+)()", nodepos);
-	if node and not host then return nil, nil, nil; end
+	if node ~= nil and host == nil then return nil, nil, nil; end
 	local resource = match(jid, "^/(.+)$", hostpos);
-	if (not host) or ((not resource) and #jid >= hostpos) then return nil, nil, nil; end
+	if (host == nil) or ((resource == nil) and #jid >= hostpos) then return nil, nil, nil; end
 	return node, host, resource;
 end
 
 local function bare(jid)
 	local node, host = split(jid);
-	if node and host then
+	if node ~= nil and host ~= nil then
 		return node.."@"..host;
 	end
 	return host;
 end
 
-local function prepped_split(jid)
+local function prepped_split(jid, strict)
 	local node, host, resource = split(jid);
-	if host and host ~= "." then
+	if host ~= nil and host ~= "." then
 		if sub(host, -1, -1) == "." then -- Strip empty root label
 			host = sub(host, 1, -2);
 		end
-		host = nameprep(host);
-		if not host then return; end
-		if node then
-			node = nodeprep(node);
-			if not node then return; end
+		host = nameprep(host, strict);
+		if host == nil then return; end
+		if node ~= nil then
+			node = nodeprep(node, strict);
+			if node == nil then return; end
 		end
-		if resource then
-			resource = resourceprep(resource);
-			if not resource then return; end
+		if resource ~= nil then
+			resource = resourceprep(resource, strict);
+			if resource == nil then return; end
 		end
 		return node, host, resource;
 	end
 end
 
 local function join(node, host, resource)
-	if not host then return end
-	if node and resource then
+	if host == nil then return end
+	if node ~= nil and resource ~= nil then
 		return node.."@"..host.."/"..resource;
-	elseif node then
+	elseif node ~= nil then
 		return node.."@"..host;
-	elseif resource then
+	elseif resource ~= nil then
 		return host.."/"..resource;
 	end
 	return host;
 end
 
-local function prep(jid)
-	local node, host, resource = prepped_split(jid);
+local function prep(jid, strict)
+	local node, host, resource = prepped_split(jid, strict);
 	return join(node, host, resource);
 end
 
@@ -107,7 +111,7 @@
 	return (select(3, split(jid)));
 end
 
-local function escape(s) return s and (s:gsub(".", escapes)); end
+local function escape(s) return s and (s:gsub("\\%x%x", backslash_escapes):gsub("[\"&'/:<>@ ]", escapes)); end
 local function unescape(s) return s and (s:gsub("\\%x%x", unescapes)); end
 
 return {
--- a/util/json.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/json.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -217,12 +217,19 @@
 end
 local function _readarray(json, index)
 	local a = {};
-	local oindex = index;
 	while true do
-		local val;
-		val, index = _readvalue(json, index + 1);
+		local val, terminated;
+		val, index, terminated = _readvalue(json, index + 1, 0x5d);
 		if val == nil then
-			if json:byte(oindex + 1) == 0x5d then return setmetatable(a, array_mt), oindex + 2; end -- "]"
+			if terminated then -- "]" found instead of value
+				if #a ~= 0 then
+					-- A non-empty array here means we processed a comma,
+					-- but it wasn't followed by a value. JSON doesn't allow
+					-- trailing commas.
+					return nil, "value expected";
+				end
+				val, index = setmetatable(a, array_mt), index+1;
+			end
 			return val, index;
 		end
 		t_insert(a, val);
@@ -294,7 +301,7 @@
 	end
 	return nil, "false parse failed";
 end
-function _readvalue(json, index)
+function _readvalue(json, index, terminator)
 	index = _skip_whitespace(json, index);
 	local b = json:byte(index);
 	-- TODO try table lookup instead of if-else?
@@ -312,6 +319,8 @@
 		return _readtrue(json, index);
 	elseif b == 0x66 then -- "f"
 		return _readfalse(json, index);
+	elseif b == terminator then
+		return nil, index, true;
 	else
 		return nil, "value expected";
 	end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/jsonpointer.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,44 @@
+local m_type = math.type or function (n)
+	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
+end;
+
+local function unescape_token(escaped_token)
+	local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~")
+	return unescaped
+end
+
+local function resolve_json_pointer(ref, path)
+	local ptr_len = #path + 1
+	for part, pos in path:gmatch("/([^/]*)()") do
+		local token = unescape_token(part)
+		if not (type(ref) == "table") then
+			return nil
+		end
+		local idx = next(ref)
+		local new_ref
+
+		if type(idx) == "string" then
+			new_ref = ref[token]
+		elseif m_type(idx) == "integer" then
+			local i = tonumber(token)
+			if token == "-" then
+				i = #ref + 1
+			end
+			new_ref = ref[i + 1]
+		else
+			return nil, "invalid-table"
+		end
+
+		if pos == ptr_len then
+			return new_ref
+		elseif type(new_ref) == "table" then
+			ref = new_ref
+		elseif not (type(ref) == "table") then
+			return nil, "invalid-path"
+		end
+
+	end
+	return ref
+end
+
+return { resolve = resolve_json_pointer }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/jsonschema.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,286 @@
+-- This file is generated from teal-src/util/jsonschema.lua
+
+local m_type = function(n)
+	return type(n) == "number" and n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
+end;
+local json = require("util.json")
+local null = json.null;
+
+local pointer = require("util.jsonpointer")
+
+local json_type_name = json.json_type_name
+
+local schema_t = {}
+
+local json_schema_object = { xml_t = {} }
+
+local function simple_validate(schema, data)
+	if schema == nil then
+		return true
+	elseif schema == "object" and type(data) == "table" then
+		return type(data) == "table" and (next(data) == nil or type((next(data, nil))) == "string")
+	elseif schema == "array" and type(data) == "table" then
+		return type(data) == "table" and (next(data) == nil or type((next(data, nil))) == "number")
+	elseif schema == "integer" then
+		return m_type(data) == schema
+	elseif schema == "null" then
+		return data == null
+	elseif type(schema) == "table" then
+		for _, one in ipairs(schema) do
+			if simple_validate(one, data) then
+				return true
+			end
+		end
+		return false
+	else
+		return type(data) == schema
+	end
+end
+
+local complex_validate
+
+local function validate(schema, data, root)
+	if type(schema) == "boolean" then
+		return schema
+	else
+		return complex_validate(schema, data, root)
+	end
+end
+
+function complex_validate(schema, data, root)
+
+	if root == nil then
+		root = schema
+	end
+
+	if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
+		local referenced = pointer.resolve(root, schema["$ref"]:sub(2))
+		if referenced ~= nil and referenced ~= root and referenced ~= schema then
+			if not validate(referenced, data, root) then
+				return false
+			end
+		end
+	end
+
+	if not simple_validate(schema.type, data) then
+		return false
+	end
+
+	if schema.type == "object" then
+		if type(data) == "table" then
+
+			for k in pairs(data) do
+				if not (type(k) == "string") then
+					return false
+				end
+			end
+		end
+	end
+
+	if schema.type == "array" then
+		if type(data) == "table" then
+
+			for i in pairs(data) do
+				if not (m_type(i) == "integer") then
+					return false
+				end
+			end
+		end
+	end
+
+	if schema["enum"] ~= nil then
+		local match = false
+		for _, v in ipairs(schema["enum"]) do
+			if v == data then
+
+				match = true
+				break
+			end
+		end
+		if not match then
+			return false
+		end
+	end
+
+	if type(data) == "string" then
+		if schema.maxLength and #data > schema.maxLength then
+			return false
+		end
+		if schema.minLength and #data < schema.minLength then
+			return false
+		end
+	end
+
+	if type(data) == "number" then
+		if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then
+			return false
+		end
+
+		if schema.maximum and not (data <= schema.maximum) then
+			return false
+		end
+
+		if schema.exclusiveMaximum and not (data < schema.exclusiveMaximum) then
+			return false
+		end
+
+		if schema.minimum and not (data >= schema.minimum) then
+			return false
+		end
+
+		if schema.exclusiveMinimum and not (data > schema.exclusiveMinimum) then
+			return false
+		end
+	end
+
+	if schema.allOf then
+		for _, sub in ipairs(schema.allOf) do
+			if not validate(sub, data, root) then
+				return false
+			end
+		end
+	end
+
+	if schema.oneOf then
+		local valid = 0
+		for _, sub in ipairs(schema.oneOf) do
+			if validate(sub, data, root) then
+				valid = valid + 1
+			end
+		end
+		if valid ~= 1 then
+			return false
+		end
+	end
+
+	if schema.anyOf then
+		local match = false
+		for _, sub in ipairs(schema.anyOf) do
+			if validate(sub, data, root) then
+				match = true
+				break
+			end
+		end
+		if not match then
+			return false
+		end
+	end
+
+	if schema["not"] then
+		if validate(schema["not"], data, root) then
+			return false
+		end
+	end
+
+	if schema["if"] ~= nil then
+		if validate(schema["if"], data, root) then
+			if schema["then"] then
+				return validate(schema["then"], data, root)
+			end
+		else
+			if schema["else"] then
+				return validate(schema["else"], data, root)
+			end
+		end
+	end
+
+	if schema.const ~= nil and schema.const ~= data then
+		return false
+	end
+
+	if type(data) == "table" then
+
+		if schema.maxItems and #data > schema.maxItems then
+			return false
+		end
+
+		if schema.minItems and #data < schema.minItems then
+			return false
+		end
+
+		if schema.required then
+			for _, k in ipairs(schema.required) do
+				if data[k] == nil then
+					return false
+				end
+			end
+		end
+
+		if schema.propertyNames ~= nil then
+			for k in pairs(data) do
+				if not validate(schema.propertyNames, k, root) then
+					return false
+				end
+			end
+		end
+
+		if schema.properties then
+			for k, sub in pairs(schema.properties) do
+				if data[k] ~= nil and not validate(sub, data[k], root) then
+					return false
+				end
+			end
+		end
+
+		if schema.additionalProperties ~= nil then
+			for k, v in pairs(data) do
+				if schema.properties == nil or schema.properties[k] == nil then
+					if not validate(schema.additionalProperties, v, root) then
+						return false
+					end
+				end
+			end
+		end
+
+		if schema.uniqueItems then
+
+			local values = {}
+			for _, v in pairs(data) do
+				if values[v] then
+					return false
+				end
+				values[v] = true
+			end
+		end
+
+		local p = 0
+		if schema.prefixItems ~= nil then
+			for i, s in ipairs(schema.prefixItems) do
+				if data[i] == nil then
+					break
+				elseif validate(s, data[i], root) then
+					p = i
+				else
+					return false
+				end
+			end
+		end
+
+		if schema.items ~= nil then
+			for i = p + 1, #data do
+				if not validate(schema.items, data[i], root) then
+					return false
+				end
+			end
+		end
+
+		if schema.contains ~= nil then
+			local found = false
+			for i = 1, #data do
+				if validate(schema.contains, data[i], root) then
+					found = true
+					break
+				end
+			end
+			if not found then
+				return false
+			end
+		end
+	end
+
+	return true
+end
+
+json_schema_object.validate = validate;
+
+return json_schema_object
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/jwt.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,51 @@
+local s_gsub = string.gsub;
+local json = require "util.json";
+local hashes = require "util.hashes";
+local base64_encode = require "util.encodings".base64.encode;
+local base64_decode = require "util.encodings".base64.decode;
+local secure_equals = require "util.hashes".equals;
+
+local b64url_rep = { ["+"] = "-", ["/"] = "_", ["="] = "", ["-"] = "+", ["_"] = "/" };
+local function b64url(data)
+	return (s_gsub(base64_encode(data), "[+/=]", b64url_rep));
+end
+local function unb64url(data)
+	return base64_decode(s_gsub(data, "[-_]", b64url_rep).."==");
+end
+
+local static_header = b64url('{"alg":"HS256","typ":"JWT"}') .. '.';
+
+local function sign(key, payload)
+	local encoded_payload = json.encode(payload);
+	local signed = static_header .. b64url(encoded_payload);
+	local signature = hashes.hmac_sha256(key, signed);
+	return signed .. "." .. b64url(signature);
+end
+
+local jwt_pattern = "^(([A-Za-z0-9-_]+)%.([A-Za-z0-9-_]+))%.([A-Za-z0-9-_]+)$"
+local function verify(key, blob)
+	local signed, bheader, bpayload, signature = string.match(blob, jwt_pattern);
+	if not signed then
+		return nil, "invalid-encoding";
+	end
+	local header = json.decode(unb64url(bheader));
+	if not header or type(header) ~= "table" then
+		return nil, "invalid-header";
+	elseif header.alg ~= "HS256" then
+		return nil, "unsupported-algorithm";
+	end
+	if not secure_equals(b64url(hashes.hmac_sha256(key, signed)), signature) then
+		return false, "signature-mismatch";
+	end
+	local payload, err = json.decode(unb64url(bpayload));
+	if err ~= nil then
+		return nil, "json-decode-error";
+	end
+	return true, payload;
+end
+
+return {
+	sign = sign;
+	verify = verify;
+};
+
--- a/util/mercurial.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/mercurial.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -19,7 +19,7 @@
 			hg_changelog:close();
 		end
 	else
-		local hg_archival,e = io.open(path.."/.hg_archival.txt");
+		local hg_archival,e = io.open(path.."/.hg_archival.txt"); -- luacheck: ignore 211/e
 		if hg_archival then
 			local repo = hg_archival:read("*l");
 			local node = hg_archival:read("*l");
--- a/util/multitable.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/multitable.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -9,7 +9,7 @@
 local select = select;
 local t_insert = table.insert;
 local pairs, next, type = pairs, next, type;
-local unpack = table.unpack or unpack; --luacheck: ignore 113 143
+local unpack = table.unpack or unpack; --luacheck: ignore 113
 
 local _ENV = nil;
 -- luacheck: std none
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/openmetrics.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,388 @@
+--[[
+This module implements a subset of the OpenMetrics Internet Draft version 00.
+
+URL: https://tools.ietf.org/html/draft-richih-opsawg-openmetrics-00
+
+The following metric types are supported:
+
+- Counter
+- Gauge
+- Histogram
+- Summary
+
+It is used by util.statsd and util.statistics to provide the OpenMetrics API.
+
+To understand what this module is about, it is useful to familiarize oneself
+with the terms MetricFamily, Metric, LabelSet, Label and MetricPoint as
+defined in the I-D linked above.
+--]]
+-- metric constructor interface:
+-- metric_ctor(..., family_name, labels, extra)
+
+local time = require "util.time".now;
+local select = select;
+local array = require "util.array";
+local log = require "util.logger".init("util.openmetrics");
+local new_multitable = require "util.multitable".new;
+local iter_multitable = require "util.multitable".iter;
+local t_concat, t_insert = table.concat, table.insert;
+local t_pack, t_unpack = require "util.table".pack, table.unpack or unpack; --luacheck: ignore 113/unpack
+
+-- BEGIN of Utility: "metric proxy"
+-- This allows to wrap a MetricFamily in a proxy which only provides the
+-- `with_labels` and `with_partial_label` methods. This allows to pre-set one
+-- or more labels on a metric family. This is used in particular via
+-- `with_partial_label` by the moduleapi in order to pre-set the `host` label
+-- on metrics created in non-global modules.
+local metric_proxy_mt = {}
+metric_proxy_mt.__index = metric_proxy_mt
+
+local function new_metric_proxy(metric_family, with_labels_proxy_fun)
+	return setmetatable({
+		_family = metric_family,
+		with_labels = function(self, ...)
+			return with_labels_proxy_fun(self._family, ...)
+		end;
+		with_partial_label = function(self, label)
+			return new_metric_proxy(self._family, function(family, ...)
+				return family:with_labels(label, ...)
+			end)
+		end
+	}, metric_proxy_mt);
+end
+
+-- END of Utility: "metric proxy"
+
+-- BEGIN Rendering helper functions (internal)
+
+local function escape(text)
+	return text:gsub("\\", "\\\\"):gsub("\"", "\\\""):gsub("\n", "\\n");
+end
+
+local function escape_name(name)
+	return name:gsub("/", "__"):gsub("[^A-Za-z0-9_]", "_"):gsub("^[^A-Za-z_]", "_%1");
+end
+
+local function repr_help(metric, docstring)
+	docstring = docstring:gsub("\\", "\\\\"):gsub("\n", "\\n");
+	return "# HELP "..escape_name(metric).." "..docstring.."\n";
+end
+
+local function repr_unit(metric, unit)
+	if not unit then
+		unit = ""
+	else
+		unit = unit:gsub("\\", "\\\\"):gsub("\n", "\\n");
+	end
+	return "# UNIT "..escape_name(metric).." "..unit.."\n";
+end
+
+-- local allowed_types = { counter = true, gauge = true, histogram = true, summary = true, untyped = true };
+-- local allowed_types = { "counter", "gauge", "histogram", "summary", "untyped" };
+local function repr_type(metric, type_)
+	-- if not allowed_types:contains(type_) then
+	-- 	return;
+	-- end
+	return "# TYPE "..escape_name(metric).." "..type_.."\n";
+end
+
+local function repr_label(key, value)
+	return key.."=\""..escape(value).."\"";
+end
+
+local function repr_labels(labelkeys, labelvalues, extra_labels)
+	local values = {}
+	if labelkeys then
+		for i, key in ipairs(labelkeys) do
+			local value = labelvalues[i]
+			t_insert(values, repr_label(escape_name(key), escape(value)));
+		end
+	end
+	if extra_labels then
+		for key, value in pairs(extra_labels) do
+			t_insert(values, repr_label(escape_name(key), escape(value)));
+		end
+	end
+	if #values == 0 then
+		return "";
+	end
+	return "{"..t_concat(values, ",").."}";
+end
+
+local function repr_sample(metric, labelkeys, labelvalues, extra_labels, value)
+	return escape_name(metric)..repr_labels(labelkeys, labelvalues, extra_labels).." "..string.format("%.17g", value).."\n";
+end
+
+-- END Rendering helper functions (internal)
+
+local function render_histogram_le(v)
+	if v == 1/0 then
+		-- I-D-00: 4.1.2.2.1:
+		--    Exposers MUST produce output for positive infinity as +Inf.
+		return "+Inf"
+	end
+
+	return string.format("%.14g", v)
+end
+
+-- BEGIN of generic MetricFamily implementation
+
+local metric_family_mt = {}
+metric_family_mt.__index = metric_family_mt
+
+local function histogram_metric_ctor(orig_ctor, buckets)
+	return function(family_name, labels, extra)
+		return orig_ctor(buckets, family_name, labels, extra)
+	end
+end
+
+local function new_metric_family(backend, type_, family_name, unit, description, label_keys, extra)
+	local metric_ctor = assert(backend[type_], "statistics backend does not support "..type_.." metrics families")
+	local labels = label_keys or {}
+	local user_labels = #labels
+	if type_ == "histogram" then
+		local buckets = extra and extra.buckets
+		if not buckets then
+			error("no buckets given for histogram metric")
+		end
+		buckets = array(buckets)
+		buckets:push(1/0)  -- must have +inf bucket
+
+		metric_ctor = histogram_metric_ctor(metric_ctor, buckets)
+	end
+
+	local data
+	if #labels == 0 then
+		data = metric_ctor(family_name, nil, extra)
+	else
+		data = new_multitable()
+	end
+
+	local mf = {
+		family_name = family_name,
+		data = data,
+		type_ = type_,
+		unit = unit,
+		description = description,
+		user_labels = user_labels,
+		label_keys = labels,
+		extra = extra,
+		_metric_ctor = metric_ctor,
+	}
+	setmetatable(mf, metric_family_mt);
+	return mf
+end
+
+function metric_family_mt:new_metric(labels)
+	return self._metric_ctor(self.family_name, labels, self.extra)
+end
+
+function metric_family_mt:clear()
+	for _, metric in self:iter_metrics() do
+		metric:reset()
+	end
+end
+
+function metric_family_mt:with_labels(...)
+	local count = select('#', ...)
+	if count ~= self.user_labels then
+		error("number of labels passed to with_labels does not match number of label keys")
+	end
+	if count == 0 then
+		return self.data
+	end
+	local metric = self.data:get(...)
+	if not metric then
+		local values = t_pack(...)
+		metric = self:new_metric(values)
+		values[values.n+1] = metric
+		self.data:set(t_unpack(values, 1, values.n+1))
+	end
+	return metric
+end
+
+function metric_family_mt:with_partial_label(label)
+	return new_metric_proxy(self, function (family, ...)
+		return family:with_labels(label, ...)
+	end)
+end
+
+function metric_family_mt:iter_metrics()
+	if #self.label_keys == 0 then
+		local done = false
+		return function()
+			if done then
+				return nil
+			end
+			done = true
+			return {}, self.data
+		end
+	end
+	local searchkeys = {};
+	local nlabels = #self.label_keys
+	for i=1,nlabels do
+		searchkeys[i] = nil;
+	end
+	local it, state = iter_multitable(self.data, t_unpack(searchkeys, 1, nlabels))
+	return function(_s)
+		local label_values = t_pack(it(_s))
+		if label_values.n == 0 then
+			return nil, nil
+		end
+		local metric = label_values[label_values.n]
+		label_values[label_values.n] = nil
+		label_values.n = label_values.n - 1
+		return label_values, metric
+	end, state
+end
+
+-- END of generic MetricFamily implementation
+
+-- BEGIN of MetricRegistry implementation
+
+
+-- Helper to test whether two metrics are "equal".
+local function equal_metric_family(mf1, mf2)
+	if mf1.type_ ~= mf2.type_ then
+		return false
+	end
+	if #mf1.label_keys ~= #mf2.label_keys then
+		return false
+	end
+	-- Ignoring unit here because in general it'll be part of the name anyway
+	-- So either the unit was moved into/out of the name (which is a valid)
+	-- thing to do on an upgrade or we would expect not to see any conflicts
+	-- anyway.
+	--[[
+	if mf1.unit ~= mf2.unit then
+		return false
+	end
+	]]
+	for i, key in ipairs(mf1.label_keys) do
+		if key ~= mf2.label_keys[i] then
+			return false
+		end
+	end
+	return true
+end
+
+-- If the unit is not empty, add it to the full name as per the I-D spec.
+local function compose_name(name, unit)
+	local full_name = name
+	if unit and unit ~= "" then
+		full_name = full_name .. "_" .. unit
+	end
+	-- TODO: prohibit certain suffixes used by metrics if where they may cause
+	-- conflicts
+	return full_name
+end
+
+local metric_registry_mt = {}
+metric_registry_mt.__index = metric_registry_mt
+
+local function new_metric_registry(backend)
+	local reg = {
+		families = {},
+		backend = backend,
+	}
+	setmetatable(reg, metric_registry_mt)
+	return reg
+end
+
+function metric_registry_mt:register_metric_family(name, metric_family)
+	local existing = self.families[name];
+	if existing then
+		if not equal_metric_family(metric_family, existing) then
+			-- We could either be strict about this, or replace the
+			-- existing metric family with the new one.
+			-- Being strict is nice to avoid programming errors /
+			-- conflicts, but causes issues when a new version of a module
+			-- is loaded.
+			--
+			-- We will thus assume that the new metric is the correct one;
+			-- That is probably OK because unless you're reaching down into
+			-- the util.openmetrics or core.statsmanager API, your metric
+			-- name is going to be scoped to `prosody_mod_$modulename`
+			-- anyway and the damage is thus controlled.
+			--
+			-- To make debugging such issues easier, we still log.
+			log("debug", "replacing incompatible existing metric family %s", name)
+			-- Below is the code to be strict.
+			--error("conflicting declarations for metric family "..name)
+		else
+			return existing
+		end
+	end
+	self.families[name] = metric_family
+	return metric_family
+end
+
+function metric_registry_mt:gauge(name, unit, description, labels, extra)
+	name = compose_name(name, unit)
+	local mf = new_metric_family(self.backend, "gauge", name, unit, description, labels, extra)
+	mf = self:register_metric_family(name, mf)
+	return mf
+end
+
+function metric_registry_mt:counter(name, unit, description, labels, extra)
+	name = compose_name(name, unit)
+	local mf = new_metric_family(self.backend, "counter", name, unit, description, labels, extra)
+	mf = self:register_metric_family(name, mf)
+	return mf
+end
+
+function metric_registry_mt:histogram(name, unit, description, labels, extra)
+	name = compose_name(name, unit)
+	local mf = new_metric_family(self.backend, "histogram", name, unit, description, labels, extra)
+	mf = self:register_metric_family(name, mf)
+	return mf
+end
+
+function metric_registry_mt:summary(name, unit, description, labels, extra)
+	name = compose_name(name, unit)
+	local mf = new_metric_family(self.backend, "summary", name, unit, description, labels, extra)
+	mf = self:register_metric_family(name, mf)
+	return mf
+end
+
+function metric_registry_mt:get_metric_families()
+	return self.families
+end
+
+function metric_registry_mt:render()
+	local answer = {};
+	for metric_family_name, metric_family in pairs(self:get_metric_families()) do
+		t_insert(answer, repr_help(metric_family_name, metric_family.description))
+		t_insert(answer, repr_unit(metric_family_name, metric_family.unit))
+		t_insert(answer, repr_type(metric_family_name, metric_family.type_))
+		for labelset, metric in metric_family:iter_metrics() do
+			for suffix, extra_labels, value in metric:iter_samples() do
+				t_insert(answer, repr_sample(metric_family_name..suffix, metric_family.label_keys, labelset, extra_labels, value))
+			end
+		end
+	end
+	t_insert(answer, "# EOF\n")
+	return t_concat(answer, "");
+end
+
+-- END of MetricRegistry implementation
+
+-- BEGIN of general helpers for implementing high-level APIs on top of OpenMetrics
+
+local function timed(metric)
+	local t0 = time()
+	local submitter = assert(metric.sample or metric.set, "metric type cannot be used with timed()")
+	return function()
+		local t1 = time()
+		submitter(metric, t1-t0)
+	end
+end
+
+-- END of general helpers
+
+return {
+	new_metric_proxy = new_metric_proxy;
+	new_metric_registry = new_metric_registry;
+	render_histogram_le = render_histogram_le;
+	timed = timed;
+}
--- a/util/paths.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/paths.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -37,8 +37,34 @@
 	end).."$";
 end
 
-function path_util.join(...)
-	return t_concat({...}, path_sep);
+function path_util.join(a, b, c, ...) -- (... : string) --> string
+	-- Optimization: Avoid creating table for most uses
+	if b then
+		if c then
+			if ... then
+				return t_concat({a,b,c,...}, path_sep);
+			end
+			return a..path_sep..b..path_sep..c;
+		end
+		return a..path_sep..b;
+	end
+	return a;
+end
+
+function path_util.complement_lua_path(installer_plugin_path)
+	-- Checking for duplicates
+	-- The commands using luarocks need the path to the directory that has the /share and /lib folders.
+	local lua_version = _VERSION:match(" (.+)$");
+	local lua_path_sep = package.config:sub(3,3);
+	local dir_sep = package.config:sub(1,1);
+	local sub_path = dir_sep.."lua"..dir_sep..lua_version..dir_sep;
+	if not string.find(package.path, installer_plugin_path, 1, true) then
+		package.path = package.path..lua_path_sep..installer_plugin_path..dir_sep.."share"..sub_path.."?.lua";
+		package.path = package.path..lua_path_sep..installer_plugin_path..dir_sep.."share"..sub_path.."?"..dir_sep.."init.lua";
+	end
+	if not string.find(package.path, installer_plugin_path, 1, true) then
+		package.cpath = package.cpath..lua_path_sep..installer_plugin_path..dir_sep.."lib"..sub_path.."?.so";
+	end
 end
 
 return path_util;
--- a/util/pluginloader.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/pluginloader.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,18 +8,23 @@
 -- luacheck: ignore 113/CFG_PLUGINDIR
 
 local dir_sep, path_sep = package.config:match("^(%S+)%s(%S+)");
+local lua_version = _VERSION:match(" (.+)$");
 local plugin_dir = {};
 for path in (CFG_PLUGINDIR or "./plugins/"):gsub("[/\\]", dir_sep):gmatch("[^"..path_sep.."]+") do
 	path = path..dir_sep; -- add path separator to path end
-	path = path:gsub(dir_sep..dir_sep.."+", dir_sep); -- coalesce multiple separaters
+	path = path:gsub(dir_sep..dir_sep.."+", dir_sep); -- coalesce multiple separators
 	plugin_dir[#plugin_dir + 1] = path;
 end
 
 local io_open = io.open;
 local envload = require "util.envload".envload;
 
-local function load_file(names)
+local pluginloader_methods = {};
+local pluginloader_mt = { __index = pluginloader_methods };
+
+function pluginloader_methods:load_file(names)
 	local file, err, path;
+	local load_filter_cb = self._options.load_filter_cb;
 	for i=1,#plugin_dir do
 		for j=1,#names do
 			path = plugin_dir[i]..names[j];
@@ -27,39 +32,49 @@
 			if file then
 				local content = file:read("*a");
 				file:close();
-				return content, path;
+				local metadata;
+				if load_filter_cb then
+					path, content, metadata = load_filter_cb(path, content);
+				end
+				if content and path then
+					return content, path, metadata;
+				end
 			end
 		end
 	end
 	return file, err;
 end
 
-local function load_resource(plugin, resource)
+function pluginloader_methods:load_resource(plugin, resource)
 	resource = resource or "mod_"..plugin..".lua";
-
 	local names = {
 		"mod_"..plugin..dir_sep..plugin..dir_sep..resource; -- mod_hello/hello/mod_hello.lua
 		"mod_"..plugin..dir_sep..resource;                  -- mod_hello/mod_hello.lua
 		plugin..dir_sep..resource;                          -- hello/mod_hello.lua
 		resource;                                           -- mod_hello.lua
+		"share"..dir_sep.."lua"..dir_sep..lua_version..dir_sep..resource;
+		"share"..dir_sep.."lua"..dir_sep..lua_version..dir_sep.."mod_"..plugin..dir_sep..resource;
 	};
 
-	return load_file(names);
+	return self:load_file(names);
 end
 
-local function load_code(plugin, resource, env)
-	local content, err = load_resource(plugin, resource);
+function pluginloader_methods:load_code(plugin, resource, env)
+	local content, err, metadata = self:load_resource(plugin, resource);
 	if not content then return content, err; end
 	local path = err;
 	local f, err = envload(content, "@"..path, env);
 	if not f then return f, err; end
-	return f, path;
+	return f, path, metadata;
 end
 
-local function load_code_ext(plugin, resource, extension, env)
-	local content, err = load_resource(plugin, resource.."."..extension);
+function pluginloader_methods:load_code_ext(plugin, resource, extension, env)
+	local content, err, metadata = self:load_resource(plugin, resource.."."..extension);
+	if not content and extension == "lib.lua" then
+		content, err, metadata = self:load_resource(plugin, resource..".lua");
+	end
 	if not content then
-		content, err = load_resource(resource, resource.."."..extension);
+		content, err, metadata = self:load_resource(resource, resource.."."..extension);
 		if not content then
 			return content, err;
 		end
@@ -67,12 +82,28 @@
 	local path = err;
 	local f, err = envload(content, "@"..path, env);
 	if not f then return f, err; end
-	return f, path;
+	return f, path, metadata;
+end
+
+local function init(options)
+	return setmetatable({
+		_options = options or {};
+	}, pluginloader_mt);
 end
 
+local function bind(self, method)
+	return function (...)
+		return method(self, ...);
+	end;
+end
+
+local default_loader = init();
+
 return {
-	load_file = load_file;
-	load_resource = load_resource;
-	load_code = load_code;
-	load_code_ext = load_code_ext;
+	load_file = bind(default_loader, default_loader.load_file);
+	load_resource = bind(default_loader, default_loader.load_resource);
+	load_code = bind(default_loader, default_loader.load_code);
+	load_code_ext = bind(default_loader, default_loader.load_code_ext);
+
+	init = init;
 };
--- a/util/promise.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/promise.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -2,6 +2,7 @@
 local promise_mt = { __name = "promise", __index = promise_methods };
 
 local xpcall = require "util.xpcall".xpcall;
+local unpack = table.unpack or unpack; --luacheck: ignore 113
 
 function promise_mt:__tostring()
 	return  "promise (" .. (self._state or "invalid") .. ")";
@@ -49,6 +50,9 @@
 	for _, cb in ipairs(cbs) do
 		cb(value);
 	end
+	-- No need to keep references to callbacks
+	promise._pending_on_fulfilled = nil;
+	promise._pending_on_rejected = nil;
 	return true;
 end
 
@@ -74,33 +78,87 @@
 	return _resolve, _reject;
 end
 
+local next_tick = function (f)
+	f();
+end
+
 local function new(f)
 	local p = setmetatable({ _state = "pending", _next = next_pending, _pending_on_fulfilled = {}, _pending_on_rejected = {} }, promise_mt);
 	if f then
-		local resolve, reject = new_resolve_functions(p);
-		local ok, ret = xpcall(f, debug.traceback, resolve, reject);
-		if not ok and p._state == "pending" then
-			reject(ret);
-		end
+		next_tick(function()
+			local resolve, reject = new_resolve_functions(p);
+			local ok, ret = xpcall(f, debug.traceback, resolve, reject);
+			if not ok and p._state == "pending" then
+				reject(ret);
+			end
+		end);
 	end
 	return p;
 end
 
 local function all(promises)
 	return new(function (resolve, reject)
-		local count, total, results = 0, #promises, {};
-		for i = 1, total do
-			promises[i]:next(function (v)
-				results[i] = v;
-				count = count + 1;
-				if count == total then
-					resolve(results);
-				end
-			end, reject);
+		local settled, results, loop_finished = 0, {}, false;
+		local total = 0;
+		for k, v in pairs(promises) do
+			if is_promise(v) then
+				total = total + 1;
+				v:next(function (value)
+					results[k] = value;
+					settled = settled + 1;
+					if settled == total and loop_finished then
+						resolve(results);
+					end
+				end, reject);
+			else
+				results[k] = v;
+			end
+		end
+		loop_finished = true;
+		if settled == total then
+			resolve(results);
 		end
 	end);
 end
 
+local function all_settled(promises)
+	return new(function (resolve)
+		local settled, results, loop_finished = 0, {}, false;
+		local total = 0;
+		for k, v in pairs(promises) do
+			if is_promise(v) then
+				total = total + 1;
+				v:next(function (value)
+					results[k] = { status = "fulfilled", value = value };
+					settled = settled + 1;
+					if settled == total and loop_finished then
+						resolve(results);
+					end
+				end, function (e)
+					results[k] = { status = "rejected", reason = e };
+					settled = settled + 1;
+					if settled == total and loop_finished then
+						resolve(results);
+					end
+				end);
+			else
+				results[k] = v;
+			end
+		end
+		loop_finished = true;
+		if settled == total then
+			resolve(results);
+		end
+	end);
+end
+
+local function join(handler, ...)
+	local promises, n = { ... }, select("#", ...);
+	return all(promises):next(function (results)
+		return handler(unpack(results, 1, n));
+	end);
+end
+
 local function race(promises)
 	return new(function (resolve, reject)
 		for i = 1, #promises do
@@ -144,9 +202,12 @@
 return {
 	new = new;
 	resolve = resolve;
+	join = join;
 	reject = reject;
 	all = all;
+	all_settled = all_settled;
 	race = race;
 	try = try;
 	is_promise = is_promise;
+	set_nexttick = function(new_next_tick) next_tick = new_next_tick; end;
 }
--- a/util/prosodyctl.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/prosodyctl.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -12,10 +12,10 @@
 local stringprep = encodings.stringprep;
 local storagemanager = require "core.storagemanager";
 local usermanager = require "core.usermanager";
+local interpolation = require "util.interpolation";
 local signal = require "util.signal";
 local set = require "util.set";
 local lfs = require "lfs";
-local pcall = pcall;
 local type = type;
 
 local nodeprep, nameprep = stringprep.nodeprep, stringprep.nameprep;
@@ -27,10 +27,22 @@
 local _G = _G;
 local prosody = prosody;
 
+local error_messages = setmetatable({
+		["invalid-username"] = "The given username is invalid in a Jabber ID";
+		["invalid-hostname"] = "The given hostname is invalid";
+		["no-password"] = "No password was supplied";
+		["no-such-user"] = "The given user does not exist on the server";
+		["no-such-host"] = "The given hostname does not exist in the config";
+		["unable-to-save-data"] = "Unable to store, perhaps you don't have permission?";
+		["no-pidfile"] = "There is no 'pidfile' option in the configuration file, see https://prosody.im/doc/prosodyctl#pidfile for help";
+		["invalid-pidfile"] = "The 'pidfile' option in the configuration file is not a string, see https://prosody.im/doc/prosodyctl#pidfile for help";
+		["no-posix"] = "The mod_posix module is not enabled in the Prosody config file, see https://prosody.im/doc/prosodyctl for more info";
+		["no-such-method"] = "This module has no commands";
+		["not-running"] = "Prosody is not running";
+		}, { __index = function (_,k) return "Error: "..(tostring(k):gsub("%-", " "):gsub("^.", string.upper)); end });
+
 -- UI helpers
-local function show_message(msg, ...)
-	print(msg:format(...));
-end
+local show_message = require "util.human.io".printf;
 
 local function show_usage(usage, desc)
 	print("Usage: ".._G.arg[0].." "..usage);
@@ -39,92 +51,19 @@
 	end
 end
 
-local function getchar(n)
-	local stty_ret = os.execute("stty raw -echo 2>/dev/null");
-	local ok, char;
-	if stty_ret == true or stty_ret == 0 then
-		ok, char = pcall(io.read, n or 1);
-		os.execute("stty sane");
-	else
-		ok, char = pcall(io.read, "*l");
-		if ok then
-			char = char:sub(1, n or 1);
-		end
-	end
-	if ok then
-		return char;
-	end
-end
-
-local function getline()
-	local ok, line = pcall(io.read, "*l");
-	if ok then
-		return line;
-	end
-end
-
-local function getpass()
-	local stty_ret, _, status_code = os.execute("stty -echo 2>/dev/null");
-	if status_code then -- COMPAT w/ Lua 5.1
-		stty_ret = status_code;
-	end
-	if stty_ret ~= 0 then
-		io.write("\027[08m"); -- ANSI 'hidden' text attribute
-	end
-	local ok, pass = pcall(io.read, "*l");
-	if stty_ret == 0 then
-		os.execute("stty sane");
-	else
-		io.write("\027[00m");
-	end
-	io.write("\n");
-	if ok then
-		return pass;
-	end
-end
-
-local function show_yesno(prompt)
-	io.write(prompt, " ");
-	local choice = getchar():lower();
-	io.write("\n");
-	if not choice:match("%a") then
-		choice = prompt:match("%[.-(%U).-%]$");
-		if not choice then return nil; end
-	end
-	return (choice == "y");
-end
-
-local function read_password()
-	local password;
-	while true do
-		io.write("Enter new password: ");
-		password = getpass();
-		if not password then
-			show_message("No password - cancelled");
-			return;
-		end
-		io.write("Retype new password: ");
-		if getpass() ~= password then
-			if not show_yesno [=[Passwords did not match, try again? [Y/n]]=] then
-				return;
-			end
-		else
-			break;
-		end
-	end
-	return password;
-end
-
-local function show_prompt(prompt)
-	io.write(prompt, " ");
-	local line = getline();
-	line = line and line:gsub("\n$","");
-	return (line and #line > 0) and line or nil;
+local function show_module_configuration_help(mod_name)
+	print("Done.")
+	print("If you installed a prosody plugin, don't forget to add its name under the 'modules_enabled' section inside your configuration file.")
+	print("Depending on the module, there might be further configuration steps required.")
+	print("")
+	print("More info about: ")
+	print("	modules_enabled: https://prosody.im/doc/modules_enabled")
+	print("	"..mod_name..": https://modules.prosody.im/"..mod_name..".html")
 end
 
 -- Server control
 local function adduser(params)
-	local user, host, password = nodeprep(params.user), nameprep(params.host), params.password;
+	local user, host, password = nodeprep(params.user, true), nameprep(params.host), params.password;
 	if not user then
 		return false, "invalid-username";
 	elseif not host then
@@ -200,7 +139,7 @@
 		return false, "pidfile-read-failed", err;
 	end
 
-	local locked, err = lfs.lock(file, "w");
+	local locked, err = lfs.lock(file, "w"); -- luacheck: ignore 211/err
 	if locked then
 		file:close();
 		return false, "pidfile-not-locked";
@@ -217,7 +156,7 @@
 end
 
 local function isrunning()
-	local ok, pid, err = getpid();
+	local ok, pid, err = getpid(); -- luacheck: ignore 211/err
 	if not ok then
 		if pid == "pidfile-read-failed" or pid == "pidfile-not-locked" then
 			-- Report as not running, since we can't open the pidfile
@@ -229,7 +168,8 @@
 	return true, signal.kill(pid, 0) == 0;
 end
 
-local function start(source_dir)
+local function start(source_dir, lua)
+	lua = lua and lua .. " " or "";
 	local ok, ret = isrunning();
 	if not ok then
 		return ok, ret;
@@ -238,9 +178,9 @@
 		return false, "already-running";
 	end
 	if not source_dir then
-		os.execute("./prosody -D");
+		os.execute(lua .. "./prosody -D");
 	else
-		os.execute(source_dir.."/../../bin/prosody -D");
+		os.execute(lua .. source_dir.."/../../bin/prosody -D");
 	end
 	return true;
 end
@@ -277,16 +217,22 @@
 	return true;
 end
 
+local render_cli = interpolation.new("%b{}", function (s) return "'"..s:gsub("'","'\\''").."'" end)
+
+local function call_luarocks(operation, mod, server)
+	local dir = prosody.paths.installer;
+	local ok, _, code = os.execute(render_cli("luarocks --lua-version={luav} {op} --tree={dir} {server&--server={server}} {mod?}", {
+				dir = dir; op = operation; mod = mod; server = server; luav = _VERSION:match("5%.%d");
+		}));
+	if type(ok) == "number" then code = ok; end
+	return code;
+end
+
 return {
 	show_message = show_message;
 	show_warning = show_message;
 	show_usage = show_usage;
-	getchar = getchar;
-	getline = getline;
-	getpass = getpass;
-	show_yesno = show_yesno;
-	read_password = read_password;
-	show_prompt = show_prompt;
+	show_module_configuration_help = show_module_configuration_help;
 	adduser = adduser;
 	user_exists = user_exists;
 	passwd = passwd;
@@ -296,4 +242,6 @@
 	start = start;
 	stop = stop;
 	reload = reload;
+	call_luarocks = call_luarocks;
+	error_messages = error_messages;
 };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/prosodyctl/cert.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,318 @@
+local lfs = require "lfs";
+
+local pctl = require "util.prosodyctl";
+local hi = require "util.human.io";
+local configmanager = require "core.configmanager";
+
+local openssl;
+
+local cert_commands = {};
+
+-- If a file already exists, ask if the user wants to use it or replace it
+-- Backups the old file if replaced
+local function use_existing(filename)
+	local attrs = lfs.attributes(filename);
+	if attrs then
+		if hi.show_yesno(filename .. " exists, do you want to replace it? [y/n]") then
+			local backup = filename..".bkp~"..os.date("%FT%T", attrs.change);
+			os.rename(filename, backup);
+			pctl.show_message("%s backed up to %s", filename, backup);
+		else
+			-- Use the existing file
+			return true;
+		end
+	end
+end
+
+local have_pposix, pposix = pcall(require, "util.pposix");
+local cert_basedir = prosody.paths.data == "." and "./certs" or prosody.paths.data;
+if have_pposix and pposix.getuid() == 0 then
+	-- FIXME should be enough to check if this directory is writable
+	local cert_dir = configmanager.get("*", "certificates") or "certs";
+	cert_basedir = configmanager.resolve_relative_path(prosody.paths.config, cert_dir);
+end
+
+function cert_commands.config(arg)
+	if #arg >= 1 and arg[1] ~= "--help" then
+		local conf_filename = cert_basedir .. "/" .. arg[1] .. ".cnf";
+		if use_existing(conf_filename) then
+			return nil, conf_filename;
+		end
+		local distinguished_name;
+		if arg[#arg]:find("^/") then
+			distinguished_name = table.remove(arg);
+		end
+		local conf = openssl.config.new();
+		conf:from_prosody(prosody.hosts, configmanager, arg);
+		if distinguished_name then
+			local dn = {};
+			for k, v in distinguished_name:gmatch("/([^=/]+)=([^/]+)") do
+				table.insert(dn, k);
+				dn[k] = v;
+			end
+			conf.distinguished_name = dn;
+		else
+			pctl.show_message("Please provide details to include in the certificate config file.");
+			pctl.show_message("Leave the field empty to use the default value or '.' to exclude the field.")
+			for _, k in ipairs(openssl._DN_order) do
+				local v = conf.distinguished_name[k];
+				if v then
+					local nv = nil;
+					if k == "commonName" then
+						v = arg[1]
+					elseif k == "emailAddress" then
+						v = "xmpp@" .. arg[1];
+					elseif k == "countryName" then
+						local tld = arg[1]:match"%.([a-z]+)$";
+						if tld and #tld == 2 and tld ~= "uk" then
+							v = tld:upper();
+						end
+					end
+					nv = hi.show_prompt(("%s (%s):"):format(k, nv or v));
+					nv = (not nv or nv == "") and v or nv;
+					if nv:find"[\192-\252][\128-\191]+" then
+						conf.req.string_mask = "utf8only"
+					end
+					conf.distinguished_name[k] = nv ~= "." and nv or nil;
+				end
+			end
+		end
+		local conf_file, err = io.open(conf_filename, "w");
+		if not conf_file then
+			pctl.show_warning("Could not open OpenSSL config file for writing");
+			pctl.show_warning("%s", err);
+			os.exit(1);
+		end
+		conf_file:write(conf:serialize());
+		conf_file:close();
+		print("");
+		pctl.show_message("Config written to %s", conf_filename);
+		return nil, conf_filename;
+	else
+		pctl.show_usage("cert config HOSTNAME [HOSTNAME+]", "Builds a certificate config file covering the supplied hostname(s)")
+	end
+end
+
+function cert_commands.key(arg)
+	if #arg >= 1 and arg[1] ~= "--help" then
+		local key_filename = cert_basedir .. "/" .. arg[1] .. ".key";
+		if use_existing(key_filename) then
+			return nil, key_filename;
+		end
+		os.remove(key_filename); -- This file, if it exists is unlikely to have write permissions
+		local key_size = tonumber(arg[2] or hi.show_prompt("Choose key size (2048):") or 2048);
+		local old_umask = pposix.umask("0377");
+		if openssl.genrsa{out=key_filename, key_size} then
+			os.execute(("chmod 400 '%s'"):format(key_filename));
+			pctl.show_message("Key written to %s", key_filename);
+			pposix.umask(old_umask);
+			return nil, key_filename;
+		end
+		pctl.show_message("There was a problem, see OpenSSL output");
+	else
+		pctl.show_usage("cert key HOSTNAME <bits>", "Generates a RSA key named HOSTNAME.key\n "
+		.."Prompts for a key size if none given")
+	end
+end
+
+function cert_commands.request(arg)
+	if #arg >= 1 and arg[1] ~= "--help" then
+		local req_filename = cert_basedir .. "/" .. arg[1] .. ".req";
+		if use_existing(req_filename) then
+			return nil, req_filename;
+		end
+		local _, key_filename = cert_commands.key({arg[1]});
+		local _, conf_filename = cert_commands.config(arg);
+		if openssl.req{new=true, key=key_filename, utf8=true, sha256=true, config=conf_filename, out=req_filename} then
+			pctl.show_message("Certificate request written to %s", req_filename);
+		else
+			pctl.show_message("There was a problem, see OpenSSL output");
+		end
+	else
+		pctl.show_usage("cert request HOSTNAME [HOSTNAME+]", "Generates a certificate request for the supplied hostname(s)")
+	end
+end
+
+function cert_commands.generate(arg)
+	if #arg >= 1 and arg[1] ~= "--help" then
+		local cert_filename = cert_basedir .. "/" .. arg[1] .. ".crt";
+		if use_existing(cert_filename) then
+			return nil, cert_filename;
+		end
+		local _, key_filename = cert_commands.key({arg[1]});
+		local _, conf_filename = cert_commands.config(arg);
+		if key_filename and conf_filename and cert_filename
+			and openssl.req{new=true, x509=true, nodes=true, key=key_filename,
+				days=365, sha256=true, utf8=true, config=conf_filename, out=cert_filename} then
+			pctl.show_message("Certificate written to %s", cert_filename);
+			print();
+		else
+			pctl.show_message("There was a problem, see OpenSSL output");
+		end
+	else
+		pctl.show_usage("cert generate HOSTNAME [HOSTNAME+]", "Generates a self-signed certificate for the current hostname(s)")
+	end
+end
+
+local function sh_esc(s)
+	return "'" .. s:gsub("'", "'\\''") .. "'";
+end
+
+local function copy(from, to, umask, owner, group)
+	local old_umask = umask and pposix.umask(umask);
+	local attrs = lfs.attributes(to);
+	if attrs then -- Move old file out of the way
+		local backup = to..".bkp~"..os.date("%FT%T", attrs.change);
+		os.rename(to, backup);
+	end
+	-- FIXME friendlier error handling, maybe move above backup back?
+	local input = assert(io.open(from));
+	local output = assert(io.open(to, "w"));
+	local data = input:read(2^11);
+	while data and output:write(data) do
+		data = input:read(2^11);
+	end
+	assert(input:close());
+	assert(output:close());
+	if not prosody.installed then
+		-- FIXME this is possibly specific to GNU chown
+		os.execute(("chown -c --reference=%s %s"):format(sh_esc(cert_basedir), sh_esc(to)));
+	elseif owner and group then
+		local ok = os.execute(("chown %s:%s %s"):format(sh_esc(owner), sh_esc(group), sh_esc(to)));
+		assert(ok == true or ok == 0, "Failed to change ownership of "..to);
+	end
+	if old_umask then pposix.umask(old_umask); end
+	return true;
+end
+
+function cert_commands.import(arg)
+	local hostnames = {};
+	-- Move hostname arguments out of arg, the rest should be a list of paths
+	while arg[1] and prosody.hosts[ arg[1] ] do
+		table.insert(hostnames, table.remove(arg, 1));
+	end
+	if hostnames[1] == nil then
+		local domains = os.getenv"RENEWED_DOMAINS"; -- Set if invoked via certbot
+		if domains then
+			for host in domains:gmatch("%S+") do
+				table.insert(hostnames, host);
+			end
+		else
+			for host in pairs(prosody.hosts) do
+				if host ~= "*" and configmanager.get(host, "enabled") ~= false then
+					table.insert(hostnames, host);
+					local http_host = configmanager.get(host, "http_host") or host;
+					if http_host ~= host then
+						table.insert(hostnames, http_host);
+					end
+				end
+			end
+		end
+	end
+	if not arg[1] or arg[1] == "--help" then -- Probably forgot the path
+		pctl.show_usage("cert import [HOSTNAME+] /path/to/certs [/other/paths/]+",
+			"Copies certificates to "..cert_basedir);
+		return 1;
+	end
+	local owner, group;
+	if pposix.getuid() == 0 then -- We need root to change ownership
+		owner = configmanager.get("*", "prosody_user") or "prosody";
+		group = configmanager.get("*", "prosody_group") or owner;
+	end
+	local cm = require "core.certmanager";
+	local files_by_name = {}
+	for _, dir in ipairs(arg) do
+		cm.index_certs(dir, files_by_name);
+	end
+	local imported = {};
+	table.sort(hostnames, function (a, b)
+		-- Try to find base domain name before sub-domains, then alphabetically, so
+		-- that the order and choice of file name is deterministic.
+		if #a == #b then
+			return a < b;
+		else
+			return #a < #b;
+		end
+	end);
+	for _, host in ipairs(hostnames) do
+		local paths = cm.find_cert_in_index(files_by_name, host);
+		if paths and imported[paths.certificate] then
+			-- One certificate, many names!
+			table.insert(imported, host);
+		elseif paths then
+			local c = copy(paths.certificate, cert_basedir .. "/" .. host .. ".crt", nil, owner, group);
+			local k = copy(paths.key, cert_basedir .. "/" .. host .. ".key", "0377", owner, group);
+			if c and k then
+				table.insert(imported, host);
+				imported[paths.certificate] = true;
+			else
+				if not c then pctl.show_warning("Could not copy certificate '%s'", paths.certificate); end
+				if not k then pctl.show_warning("Could not copy key '%s'", paths.key); end
+			end
+		else
+			-- TODO Say where we looked
+			pctl.show_warning("No certificate for host %s found :(", host);
+		end
+		-- TODO Additional checks
+		-- Certificate names matches the hostname
+		-- Private key matches public key in certificate
+	end
+	if imported[1] then
+		pctl.show_message("Imported certificate and key for hosts %s", table.concat(imported, ", "));
+		local ok, err = pctl.reload();
+		if not ok and err ~= "not-running" then
+			pctl.show_message(pctl.error_messages[err]);
+		end
+	else
+		pctl.show_warning("No certificates imported :(");
+		return 1;
+	end
+end
+
+local function cert(arg)
+	if #arg >= 1 and arg[1] ~= "--help" then
+		openssl = require "util.openssl";
+		lfs = require "lfs";
+		local cert_dir_attrs = lfs.attributes(cert_basedir);
+		if not cert_dir_attrs then
+			pctl.show_warning("The directory %s does not exist", cert_basedir);
+			return 1; -- TODO Should we create it?
+		end
+		local uid = pposix.getuid();
+		if uid ~= 0 and uid ~= cert_dir_attrs.uid then
+			pctl.show_warning("The directory %s is not owned by the current user, won't be able to write files to it", cert_basedir);
+			return 1;
+		elseif not cert_dir_attrs.permissions then -- COMPAT with LuaFilesystem < 1.6.2 (hey CentOS!)
+			pctl.show_message("Unable to check permissions on %s (LuaFilesystem 1.6.2+ required)", cert_basedir);
+			pctl.show_message("Please confirm that Prosody (and only Prosody) can write to this directory)");
+		elseif cert_dir_attrs.permissions:match("^%.w..%-..%-.$") then
+			pctl.show_warning("The directory %s not only writable by its owner", cert_basedir);
+			return 1;
+		end
+		local subcmd = table.remove(arg, 1);
+		if type(cert_commands[subcmd]) == "function" then
+			if subcmd ~= "import" then -- hostnames are optional for import
+				if not arg[1] then
+					pctl.show_message"You need to supply at least one hostname"
+					arg = { "--help" };
+				end
+				if arg[1] ~= "--help" and not prosody.hosts[arg[1]] then
+					pctl.show_message(pctl.error_messages["no-such-host"]);
+					return 1;
+				end
+			end
+			return cert_commands[subcmd](arg);
+		elseif subcmd == "check" then
+			return require "util.prosodyctl.check".check({"certs"});
+		end
+	end
+	pctl.show_usage("cert config|request|generate|key|import", "Helpers for generating X.509 certificates and keys.")
+	for _, cmd in pairs(cert_commands) do
+		print()
+		cmd{ "--help" }
+	end
+end
+
+return {
+	cert = cert;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/prosodyctl/check.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,1326 @@
+local configmanager = require "core.configmanager";
+local show_usage = require "util.prosodyctl".show_usage;
+local show_warning = require "util.prosodyctl".show_warning;
+local is_prosody_running = require "util.prosodyctl".isrunning;
+local parse_args = require "util.argparse".parse;
+local dependencies = require "util.dependencies";
+local socket = require "socket";
+local socket_url = require "socket.url";
+local jid_split = require "util.jid".prepped_split;
+local modulemanager = require "core.modulemanager";
+local async = require "util.async";
+local httputil = require "util.http";
+
+local function check_ojn(check_type, target_host)
+	local http = require "net.http"; -- .new({});
+	local json = require "util.json";
+
+	local response, err = async.wait_for(http.request(
+		("https://observe.jabber.network/api/v1/check/%s"):format(httputil.urlencode(check_type)),
+		{
+			method="POST",
+			headers={["Accept"] = "application/json"; ["Content-Type"] = "application/json"},
+			body=json.encode({target=target_host}),
+		}));
+
+	if not response then
+		return false, err;
+	end
+
+	if response.code ~= 200 then
+		return false, ("API replied with non-200 code: %d"):format(response.code);
+	end
+
+	local decoded_body, err = json.decode(response.body);
+	if decoded_body == nil then
+		return false, ("Failed to parse API JSON: %s"):format(err)
+	end
+
+	local success = decoded_body["success"];
+	return success == true, nil;
+end
+
+local function check_probe(base_url, probe_module, target)
+	local http = require "net.http"; -- .new({});
+	local params = httputil.formencode({ module = probe_module; target = target })
+	local response, err = async.wait_for(http.request(base_url .. "?" .. params));
+
+	if not response then return false, err; end
+
+	if response.code ~= 200 then return false, ("API replied with non-200 code: %d"):format(response.code); end
+
+	for line in response.body:gmatch("[^\r\n]+") do
+		local probe_success = line:match("^probe_success%s+(%d+)");
+
+		if probe_success == "1" then
+			return true;
+		elseif probe_success == "0" then
+			return false;
+		end
+	end
+	return false, "Probe endpoint did not return a success status";
+end
+
+local function check_turn_service(turn_service, ping_service)
+	local ip = require "util.ip";
+	local stun = require "net.stun";
+
+	-- Create UDP socket for communication with the server
+	local sock = assert(require "socket".udp());
+	sock:setsockname("*", 0);
+	sock:setpeername(turn_service.host, turn_service.port);
+	sock:settimeout(10);
+
+	-- Helper function to receive a packet
+	local function receive_packet()
+		local raw_packet, err = sock:receive();
+		if not raw_packet then
+			return nil, err;
+		end
+		return stun.new_packet():deserialize(raw_packet);
+	end
+
+	local result = { warnings = {} };
+
+	-- Send a "binding" query, i.e. a request for our external IP/port
+	local bind_query = stun.new_packet("binding", "request");
+	bind_query:add_attribute("software", "prosodyctl check turn");
+	sock:send(bind_query:serialize());
+
+	local bind_result, err = receive_packet();
+	if not bind_result then
+		result.error = "No STUN response: "..err;
+		return result;
+	elseif bind_result:is_err_resp() then
+		result.error = ("STUN server returned error: %d (%s)"):format(bind_result:get_error());
+		return result;
+	elseif not bind_result:is_success_resp() then
+		result.error = ("Unexpected STUN response: %d (%s)"):format(bind_result:get_type());
+		return result;
+	end
+
+	result.external_ip = bind_result:get_xor_mapped_address();
+	if not result.external_ip then
+		result.error = "STUN server did not return an address";
+		return result;
+	end
+	if ip.new_ip(result.external_ip.address).private then
+		table.insert(result.warnings, "STUN returned a private IP! Is the TURN server behind a NAT and misconfigured?");
+	end
+
+	-- Send a TURN "allocate" request. Expected to fail due to auth, but
+	-- necessary to obtain a valid realm/nonce from the server.
+	local pre_request = stun.new_packet("allocate", "request");
+	sock:send(pre_request:serialize());
+
+	local pre_result, err = receive_packet();
+	if not pre_result then
+		result.error = "No initial TURN response: "..err;
+		return result;
+	elseif pre_result:is_success_resp() then
+		result.error = "TURN server does not have authentication enabled";
+		return result;
+	end
+
+	local realm = pre_result:get_attribute("realm");
+	local nonce = pre_result:get_attribute("nonce");
+
+	if not realm then
+		table.insert(result.warnings, "TURN server did not return an authentication realm. Is authentication enabled?");
+	end
+	if not nonce then
+		table.insert(result.warnings, "TURN server did not return a nonce");
+	end
+
+	-- Use the configured secret to obtain temporary user/pass credentials
+	local turn_user, turn_pass = stun.get_user_pass_from_secret(turn_service.secret);
+
+	-- Send a TURN allocate request, will fail if auth is wrong
+	local alloc_request = stun.new_packet("allocate", "request");
+	alloc_request:add_requested_transport("udp");
+	alloc_request:add_attribute("username", turn_user);
+	if realm then
+		alloc_request:add_attribute("realm", realm);
+	end
+	if nonce then
+		alloc_request:add_attribute("nonce", nonce);
+	end
+	local key = stun.get_long_term_auth_key(realm or turn_service.host, turn_user, turn_pass);
+	alloc_request:add_message_integrity(key);
+	sock:send(alloc_request:serialize());
+
+	-- Check the response
+	local alloc_response, err = receive_packet();
+	if not alloc_response then
+		result.error = "TURN server did not response to allocation request: "..err;
+		return result;
+	elseif alloc_response:is_err_resp() then
+		result.error = ("TURN allocation failed: %d (%s)"):format(alloc_response:get_error());
+		return result;
+	elseif not alloc_response:is_success_resp() then
+		result.error = ("Unexpected TURN response: %d (%s)"):format(alloc_response:get_type());
+		return result;
+	end
+
+	result.relayed_addresses = alloc_response:get_xor_relayed_addresses();
+
+	if not ping_service then
+		-- Success! We won't be running the relay test.
+		return result;
+	end
+
+	-- Run the relay test - i.e. send a binding request to ping_service
+	-- and receive a response.
+
+	-- Resolve the IP of the ping service
+	local ping_host, ping_port = ping_service:match("^([^:]+):(%d+)$");
+	if ping_host then
+		ping_port = tonumber(ping_port);
+	else
+		-- Only a hostname specified, use default STUN port
+		ping_host, ping_port = ping_service, 3478;
+	end
+
+	if ping_host == turn_service.host then
+		result.error = ("Unable to perform ping test: please supply an external STUN server address. See https://prosody.im/doc/turn#prosodyctl-check");
+		return result;
+	end
+
+	local ping_service_ip, err = socket.dns.toip(ping_host);
+	if not ping_service_ip then
+		result.error = "Unable to resolve ping service hostname: "..err;
+		return result;
+	end
+
+	-- Ask the TURN server to allow packets from the ping service IP
+	local perm_request = stun.new_packet("create-permission");
+	perm_request:add_xor_peer_address(ping_service_ip);
+	perm_request:add_attribute("username", turn_user);
+	if realm then
+		perm_request:add_attribute("realm", realm);
+	end
+	if nonce then
+		perm_request:add_attribute("nonce", nonce);
+	end
+	perm_request:add_message_integrity(key);
+	sock:send(perm_request:serialize());
+
+	local perm_response, err = receive_packet();
+	if not perm_response then
+		result.error = "No response from TURN server when requesting peer permission: "..err;
+		return result;
+	elseif perm_response:is_err_resp() then
+		result.error = ("TURN permission request failed: %d (%s)"):format(perm_response:get_error());
+		return result;
+	elseif not perm_response:is_success_resp() then
+		result.error = ("Unexpected TURN response: %d (%s)"):format(perm_response:get_type());
+		return result;
+	end
+
+	-- Ask the TURN server to relay a STUN binding request to the ping server
+	local ping_data = stun.new_packet("binding"):serialize();
+
+	local ping_request = stun.new_packet("send", "indication");
+	ping_request:add_xor_peer_address(ping_service_ip, ping_port);
+	ping_request:add_attribute("data", ping_data);
+	ping_request:add_attribute("username", turn_user);
+	if realm then
+		ping_request:add_attribute("realm", realm);
+	end
+	if nonce then
+		ping_request:add_attribute("nonce", nonce);
+	end
+	ping_request:add_message_integrity(key);
+	sock:send(ping_request:serialize());
+
+	local ping_response, err = receive_packet();
+	if not ping_response then
+		result.error = "No response from ping server ("..ping_service_ip.."): "..err;
+		return result;
+	elseif not ping_response:is_indication() or select(2, ping_response:get_method()) ~= "data" then
+		result.error = ("Unexpected TURN response: %s %s"):format(select(2, ping_response:get_method()), select(2, ping_response:get_type()));
+		return result;
+	end
+
+	local pong_data = ping_response:get_attribute("data");
+	if not pong_data then
+		result.error = "No data relayed from remote server";
+		return result;
+	end
+	local pong = stun.new_packet():deserialize(pong_data);
+
+	result.external_ip_pong = pong:get_xor_mapped_address();
+	if not result.external_ip_pong then
+		result.error = "Ping server did not return an address";
+		return result;
+	end
+
+	local relay_address_found, relay_port_matches;
+	for _, relayed_address in ipairs(result.relayed_addresses) do
+		if relayed_address.address == result.external_ip_pong.address then
+			relay_address_found = true;
+			relay_port_matches = result.external_ip_pong.port == relayed_address.port;
+		end
+	end
+	if not relay_address_found then
+		table.insert(result.warnings, "TURN external IP vs relay address mismatch! Is the TURN server behind a NAT and misconfigured?");
+	elseif not relay_port_matches then
+		table.insert(result.warnings, "External port does not match reported relay port! This is probably caused by a NAT in front of the TURN server.");
+	end
+
+	--
+
+	return result;
+end
+
+local function skip_bare_jid_hosts(host)
+	if jid_split(host) then
+		-- See issue #779
+		return false;
+	end
+	return true;
+end
+
+local check_opts = {
+	short_params = {
+		h = "help", v = "verbose";
+	};
+	value_params = {
+		ping = true;
+	};
+};
+
+local function check(arg)
+	if arg[1] == "help" or arg[1] == "--help" then
+		show_usage([[check]], [[Perform basic checks on your Prosody installation]]);
+		return 1;
+	end
+	local what = table.remove(arg, 1);
+	local opts, opts_err, opts_info = parse_args(arg, check_opts);
+	if opts_err == "missing-value" then
+		print("Error: Expected a value after '"..opts_info.."'");
+		return 1;
+	elseif opts_err == "param-not-found" then
+		print("Error: Unknown parameter: "..opts_info);
+		return 1;
+	end
+	local array = require "util.array";
+	local set = require "util.set";
+	local it = require "util.iterators";
+	local ok = true;
+	local function disabled_hosts(host, conf) return host ~= "*" and conf.enabled ~= false; end
+	local function enabled_hosts() return it.filter(disabled_hosts, pairs(configmanager.getconfig())); end
+	if not (what == nil or what == "disabled" or what == "config" or what == "dns" or what == "certs" or what == "connectivity" or what == "turn") then
+		show_warning("Don't know how to check '%s'. Try one of 'config', 'dns', 'certs', 'disabled', 'turn' or 'connectivity'.", what);
+		show_warning("Note: The connectivity check will connect to a remote server.");
+		return 1;
+	end
+	if not what or what == "disabled" then
+		local disabled_hosts_set = set.new();
+		for host, host_options in it.filter("*", pairs(configmanager.getconfig())) do
+			if host_options.enabled == false then
+				disabled_hosts_set:add(host);
+			end
+		end
+		if not disabled_hosts_set:empty() then
+			local msg = "Checks will be skipped for these disabled hosts: %s";
+			if what then msg = "These hosts are disabled: %s"; end
+			show_warning(msg, tostring(disabled_hosts_set));
+			if what then return 0; end
+			print""
+		end
+	end
+	if not what or what == "config" then
+		print("Checking config...");
+
+		if what == "config" then
+			local files = configmanager.files();
+			print("    The following configuration files have been loaded:");
+			print("      -  "..table.concat(files, "\n      -  "));
+		end
+
+		local obsolete = set.new({ --> remove
+			"archive_cleanup_interval",
+			"cross_domain_bosh",
+			"cross_domain_websocket",
+			"dns_timeout",
+			"muc_log_cleanup_interval",
+			"s2s_dns_resolvers",
+			"setgid",
+			"setuid",
+		});
+		local function instead_use(kind, name, value)
+			if kind == "option" then
+				if value then
+					return string.format("instead, use '%s = %q'", name, value);
+				else
+					return string.format("instead, use '%s'", name);
+				end
+			elseif kind == "module" then
+				return string.format("instead, add %q to '%s'", name, value or "modules_enabled");
+			elseif kind == "community" then
+				return string.format("instead, add %q from %s", name, value or "prosody-modules");
+			end
+			return kind
+		end
+		local deprecated_replacements = {
+			anonymous_login = instead_use("option", "authentication", "anonymous");
+			daemonize = "instead, use the --daemonize/-D or --foreground/-F command line flags";
+			disallow_s2s = instead_use("module", "s2s");
+			no_daemonize = "instead, use the --daemonize/-D or --foreground/-F command line flags";
+			require_encryption = "instead, use 'c2s_require_encryption' and 's2s_require_encryption'";
+			vcard_compatibility = instead_use("community", "mod_compat_vcard");
+			use_libevent = instead_use("option", "network_backend", "event");
+			whitelist_registration_only = instead_use("option", "allowlist_registration_only");
+			registration_whitelist = instead_use("option", "registration_allowlist");
+			registration_blacklist = instead_use("option", "registration_blocklist");
+			blacklist_on_registration_throttle_overload = instead_use("blocklist_on_registration_throttle_overload");
+		};
+		-- FIXME all the singular _port and _interface options are supposed to be deprecated too
+		local deprecated_ports = { bosh = "http", legacy_ssl = "c2s_direct_tls" };
+		local port_suffixes = set.new({ "port", "ports", "interface", "interfaces", "ssl" });
+		for port, replacement in pairs(deprecated_ports) do
+			for suffix in port_suffixes do
+				local rsuffix = (suffix == "port" or suffix == "interface") and suffix.."s" or suffix;
+				deprecated_replacements[port.."_"..suffix] = "instead, use '"..replacement.."_"..rsuffix.."'"
+			end
+		end
+		local deprecated = set.new(array.collect(it.keys(deprecated_replacements)));
+		local known_global_options = set.new({
+			"access_control_allow_credentials",
+			"access_control_allow_headers",
+			"access_control_allow_methods",
+			"access_control_max_age",
+			"admin_socket",
+			"body_size_limit",
+			"bosh_max_inactivity",
+			"bosh_max_polling",
+			"bosh_max_wait",
+			"buffer_size_limit",
+			"c2s_close_timeout",
+			"c2s_stanza_size_limit",
+			"c2s_tcp_keepalives",
+			"c2s_timeout",
+			"component_stanza_size_limit",
+			"component_tcp_keepalives",
+			"consider_bosh_secure",
+			"consider_websocket_secure",
+			"console_banner",
+			"console_prettyprint_settings",
+			"daemonize",
+			"gc",
+			"http_default_host",
+			"http_errors_always_show",
+			"http_errors_default_message",
+			"http_errors_detailed",
+			"http_errors_messages",
+			"http_max_buffer_size",
+			"http_max_content_size",
+			"installer_plugin_path",
+			"limits",
+			"limits_resolution",
+			"log",
+			"multiplex_buffer_size",
+			"network_backend",
+			"network_default_read_size",
+			"network_settings",
+			"openmetrics_allow_cidr",
+			"openmetrics_allow_ips",
+			"pidfile",
+			"plugin_paths",
+			"plugin_server",
+			"prosodyctl_timeout",
+			"prosody_group",
+			"prosody_user",
+			"run_as_root",
+			"s2s_close_timeout",
+			"s2s_insecure_domains",
+			"s2s_require_encryption",
+			"s2s_secure_auth",
+			"s2s_secure_domains",
+			"s2s_stanza_size_limit",
+			"s2s_tcp_keepalives",
+			"s2s_timeout",
+			"statistics",
+			"statistics_config",
+			"statistics_interval",
+			"tcp_keepalives",
+			"tls_profile",
+			"trusted_proxies",
+			"umask",
+			"use_dane",
+			"use_ipv4",
+			"use_ipv6",
+			"websocket_frame_buffer_limit",
+			"websocket_frame_fragment_limit",
+			"websocket_get_response_body",
+			"websocket_get_response_text",
+		});
+		local config = configmanager.getconfig();
+		-- Check that we have any global options (caused by putting a host at the top)
+		if it.count(it.filter("log", pairs(config["*"]))) == 0 then
+			ok = false;
+			print("");
+			print("    No global options defined. Perhaps you have put a host definition at the top")
+			print("    of the config file? They should be at the bottom, see https://prosody.im/doc/configure#overview");
+		end
+		if it.count(enabled_hosts()) == 0 then
+			ok = false;
+			print("");
+			if it.count(it.filter("*", pairs(config))) == 0 then
+				print("    No hosts are defined, please add at least one VirtualHost section")
+			elseif config["*"]["enabled"] == false then
+				print("    No hosts are enabled. Remove enabled = false from the global section or put enabled = true under at least one VirtualHost section")
+			else
+				print("    All hosts are disabled. Remove enabled = false from at least one VirtualHost section")
+			end
+		end
+		if not config["*"].modules_enabled then
+			print("    No global modules_enabled is set?");
+			local suggested_global_modules;
+			for host, options in enabled_hosts() do --luacheck: ignore 213/host
+				if not options.component_module and options.modules_enabled then
+					suggested_global_modules = set.intersection(suggested_global_modules or set.new(options.modules_enabled), set.new(options.modules_enabled));
+				end
+			end
+			if suggested_global_modules and not suggested_global_modules:empty() then
+				print("    Consider moving these modules into modules_enabled in the global section:")
+				print("    "..tostring(suggested_global_modules / function (x) return ("%q"):format(x) end));
+			end
+			print();
+		end
+
+		do -- Check for modules enabled both normally and as components
+			local modules = set.new(config["*"]["modules_enabled"]);
+			for host, options in enabled_hosts() do
+				local component_module = options.component_module;
+				if component_module and modules:contains(component_module) then
+					print(("    mod_%s is enabled both in modules_enabled and as Component %q %q"):format(component_module, host, component_module));
+					print("    This means the service is enabled on all VirtualHosts as well as the Component.");
+					print("    Are you sure this what you want? It may cause unexpected behaviour.");
+				end
+			end
+		end
+
+		-- Check for global options under hosts
+		local global_options = set.new(it.to_array(it.keys(config["*"])));
+		local obsolete_global_options = set.intersection(global_options, obsolete);
+		if not obsolete_global_options:empty() then
+			print("");
+			print("    You have some obsolete options you can remove from the global section:");
+			print("    "..tostring(obsolete_global_options))
+			ok = false;
+		end
+		local deprecated_global_options = set.intersection(global_options, deprecated);
+		if not deprecated_global_options:empty() then
+			print("");
+			print("    You have some deprecated options in the global section:");
+			for option in deprecated_global_options do
+				print(("    '%s' -- %s"):format(option, deprecated_replacements[option]));
+			end
+			ok = false;
+		end
+		for host, options in it.filter(function (h) return h ~= "*" end, pairs(configmanager.getconfig())) do
+			local host_options = set.new(it.to_array(it.keys(options)));
+			local misplaced_options = set.intersection(host_options, known_global_options);
+			for name in pairs(options) do
+				if name:match("^interfaces?")
+				or name:match("_ports?$") or name:match("_interfaces?$")
+				or (name:match("_ssl$") and not name:match("^[cs]2s_ssl$")) then
+					misplaced_options:add(name);
+				end
+			end
+			-- FIXME These _could_ be misplaced, but we would have to check where the corresponding module is loaded to be sure
+			misplaced_options:exclude(set.new({ "external_service_port", "turn_external_port" }));
+			if not misplaced_options:empty() then
+				ok = false;
+				print("");
+				local n = it.count(misplaced_options);
+				print("    You have "..n.." option"..(n>1 and "s " or " ").."set under "..host.." that should be");
+				print("    in the global section of the config file, above any VirtualHost or Component definitions,")
+				print("    see https://prosody.im/doc/configure#overview for more information.")
+				print("");
+				print("    You need to move the following option"..(n>1 and "s" or "")..": "..table.concat(it.to_array(misplaced_options), ", "));
+			end
+		end
+		for host, options in enabled_hosts() do
+			local host_options = set.new(it.to_array(it.keys(options)));
+			local subdomain = host:match("^[^.]+");
+			if not(host_options:contains("component_module")) and (subdomain == "jabber" or subdomain == "xmpp"
+			   or subdomain == "chat" or subdomain == "im") then
+				print("");
+				print("    Suggestion: If "..host.. " is a new host with no real users yet, consider renaming it now to");
+				print("     "..host:gsub("^[^.]+%.", "")..". You can use SRV records to redirect XMPP clients and servers to "..host..".");
+				print("     For more information see: https://prosody.im/doc/dns");
+			end
+		end
+		local all_modules = set.new(config["*"].modules_enabled);
+		local all_options = set.new(it.to_array(it.keys(config["*"])));
+		for host in enabled_hosts() do
+			all_options:include(set.new(it.to_array(it.keys(config[host]))));
+			all_modules:include(set.new(config[host].modules_enabled));
+		end
+		for mod in all_modules do
+			if mod:match("^mod_") then
+				print("");
+				print("    Modules in modules_enabled should not have the 'mod_' prefix included.");
+				print("    Change '"..mod.."' to '"..mod:match("^mod_(.*)").."'.");
+			elseif mod:match("^auth_") then
+				print("");
+				print("    Authentication modules should not be added to modules_enabled,");
+				print("    but be specified in the 'authentication' option.");
+				print("    Remove '"..mod.."' from modules_enabled and instead add");
+				print("        authentication = '"..mod:match("^auth_(.*)").."'");
+				print("    For more information see https://prosody.im/doc/authentication");
+			elseif mod:match("^storage_") then
+				print("");
+				print("    storage modules should not be added to modules_enabled,");
+				print("    but be specified in the 'storage' option.");
+				print("    Remove '"..mod.."' from modules_enabled and instead add");
+				print("        storage = '"..mod:match("^storage_(.*)").."'");
+				print("    For more information see https://prosody.im/doc/storage");
+			end
+		end
+		if all_modules:contains("vcard") and all_modules:contains("vcard_legacy") then
+			print("");
+			print("    Both mod_vcard_legacy and mod_vcard are enabled but they conflict");
+			print("    with each other. Remove one.");
+		end
+		if all_modules:contains("pep") and all_modules:contains("pep_simple") then
+			print("");
+			print("    Both mod_pep_simple and mod_pep are enabled but they conflict");
+			print("    with each other. Remove one.");
+		end
+		for host, host_config in pairs(config) do --luacheck: ignore 213/host
+			if type(rawget(host_config, "storage")) == "string" and rawget(host_config, "default_storage") then
+				print("");
+				print("    The 'default_storage' option is not needed if 'storage' is set to a string.");
+				break;
+			end
+		end
+		local require_encryption = set.intersection(all_options, set.new({
+			"require_encryption", "c2s_require_encryption", "s2s_require_encryption"
+		})):empty();
+		local ssl = dependencies.softreq"ssl";
+		if not ssl then
+			if not require_encryption then
+				print("");
+				print("    You require encryption but LuaSec is not available.");
+				print("    Connections will fail.");
+				ok = false;
+			end
+		elseif not ssl.loadcertificate then
+			if all_options:contains("s2s_secure_auth") then
+				print("");
+				print("    You have set s2s_secure_auth but your version of LuaSec does ");
+				print("    not support certificate validation, so all s2s connections will");
+				print("    fail.");
+				ok = false;
+			elseif all_options:contains("s2s_secure_domains") then
+				local secure_domains = set.new();
+				for host in enabled_hosts() do
+					if config[host].s2s_secure_auth == true then
+						secure_domains:add("*");
+					else
+						secure_domains:include(set.new(config[host].s2s_secure_domains));
+					end
+				end
+				if not secure_domains:empty() then
+					print("");
+					print("    You have set s2s_secure_domains but your version of LuaSec does ");
+					print("    not support certificate validation, so s2s connections to/from ");
+					print("    these domains will fail.");
+					ok = false;
+				end
+			end
+		elseif require_encryption and not all_modules:contains("tls") then
+			print("");
+			print("    You require encryption but mod_tls is not enabled.");
+			print("    Connections will fail.");
+			ok = false;
+		end
+
+		do
+			local global_modules = set.new(config["*"].modules_enabled);
+			local registration_enabled_hosts = {};
+			for host in enabled_hosts() do
+				local host_modules = set.new(config[host].modules_enabled) + global_modules;
+				local allow_registration = config[host].allow_registration;
+				local mod_register = host_modules:contains("register");
+				local mod_register_ibr = host_modules:contains("register_ibr");
+				local mod_invites_register = host_modules:contains("invites_register");
+				local registration_invite_only = config[host].registration_invite_only;
+				local is_vhost = not config[host].component_module;
+				if is_vhost and (mod_register_ibr or (mod_register and allow_registration))
+				   and not (mod_invites_register and registration_invite_only) then
+					table.insert(registration_enabled_hosts, host);
+				end
+			end
+			if #registration_enabled_hosts > 0 then
+				table.sort(registration_enabled_hosts);
+				print("");
+				print("    Public registration is enabled on:");
+				print("        "..table.concat(registration_enabled_hosts, ", "));
+				print("");
+				print("        If this is intentional, review our guidelines on running a public server");
+				print("        at https://prosody.im/doc/public_servers - otherwise, consider switching to");
+				print("        invite-based registration, which is more secure.");
+			end
+		end
+
+		do
+			local orphan_components = {};
+			local referenced_components = set.new();
+			local enabled_hosts_set = set.new();
+			for host, host_options in it.filter("*", pairs(configmanager.getconfig())) do
+				if host_options.enabled ~= false then
+					enabled_hosts_set:add(host);
+					for _, disco_item in ipairs(host_options.disco_items or {}) do
+						referenced_components:add(disco_item[1]);
+					end
+				end
+			end
+			for host, host_config in it.filter(skip_bare_jid_hosts, enabled_hosts()) do
+				local is_component = not not host_config.component_module;
+				if is_component then
+					local parent_domain = host:match("^[^.]+%.(.+)$");
+					local is_orphan = not (enabled_hosts_set:contains(parent_domain) or referenced_components:contains(host));
+					if is_orphan then
+						table.insert(orphan_components, host);
+					end
+				end
+			end
+			if #orphan_components > 0 then
+				table.sort(orphan_components);
+				print("");
+				print("    Your configuration contains the following unreferenced components:\n");
+				print("        "..table.concat(orphan_components, "\n        "));
+				print("");
+				print("    Clients may not be able to discover these services because they are not linked to");
+				print("    any VirtualHost. They are automatically linked if they are direct subdomains of a");
+				print("    VirtualHost. Alternatively, you can explicitly link them using the disco_items option.");
+				print("    For more information see https://prosody.im/doc/modules/mod_disco#items");
+			end
+		end
+
+		print("Done.\n");
+	end
+	if not what or what == "dns" then
+		local dns = require "net.dns";
+		pcall(function ()
+			local unbound = require"net.unbound";
+			dns = unbound.dns;
+		end)
+		local idna = require "util.encodings".idna;
+		local ip = require "util.ip";
+		local c2s_ports = set.new(configmanager.get("*", "c2s_ports") or {5222});
+		local s2s_ports = set.new(configmanager.get("*", "s2s_ports") or {5269});
+		local c2s_tls_ports = set.new(configmanager.get("*", "c2s_direct_tls_ports") or {});
+		local s2s_tls_ports = set.new(configmanager.get("*", "s2s_direct_tls_ports") or {});
+
+		if set.new(configmanager.get("*", "modules_enabled")):contains("net_multiplex") then
+			local multiplex_ports = set.new(configmanager.get("*", "ports") or {});
+			local multiplex_tls_ports = set.new(configmanager.get("*", "ssl_ports") or {});
+			if not multiplex_ports:empty() then
+				c2s_ports = c2s_ports + multiplex_ports;
+				s2s_ports = s2s_ports + multiplex_ports;
+			end
+			if not multiplex_tls_ports:empty() then
+				c2s_tls_ports = c2s_tls_ports + multiplex_tls_ports;
+				s2s_tls_ports = s2s_tls_ports + multiplex_tls_ports;
+			end
+		end
+
+		local c2s_srv_required, s2s_srv_required, c2s_tls_srv_required, s2s_tls_srv_required;
+		if not c2s_ports:contains(5222) then
+			c2s_srv_required = true;
+		end
+		if not s2s_ports:contains(5269) then
+			s2s_srv_required = true;
+		end
+		if not c2s_tls_ports:empty() then
+			c2s_tls_srv_required = true;
+		end
+		if not s2s_tls_ports:empty() then
+			s2s_tls_srv_required = true;
+		end
+
+		local problem_hosts = set.new();
+
+		local external_addresses, internal_addresses = set.new(), set.new();
+
+		local fqdn = socket.dns.tohostname(socket.dns.gethostname());
+		if fqdn then
+			do
+				local res = dns.lookup(idna.to_ascii(fqdn), "A");
+				if res then
+					for _, record in ipairs(res) do
+						external_addresses:add(record.a);
+					end
+				end
+			end
+			do
+				local res = dns.lookup(idna.to_ascii(fqdn), "AAAA");
+				if res then
+					for _, record in ipairs(res) do
+						external_addresses:add(record.aaaa);
+					end
+				end
+			end
+		end
+
+		local local_addresses = require"util.net".local_addresses() or {};
+
+		for addr in it.values(local_addresses) do
+			if not ip.new_ip(addr).private then
+				external_addresses:add(addr);
+			else
+				internal_addresses:add(addr);
+			end
+		end
+
+		-- Allow admin to specify additional (e.g. undiscoverable) IP addresses in the config
+		for _, address in ipairs(configmanager.get("*", "external_addresses") or {}) do
+			external_addresses:add(address);
+		end
+
+		if external_addresses:empty() then
+			print("");
+			print("   Failed to determine the external addresses of this server. Checks may be inaccurate.");
+			c2s_srv_required, s2s_srv_required = true, true;
+		end
+
+		local v6_supported = not not socket.tcp6;
+		local use_ipv4 = configmanager.get("*", "use_ipv4") ~= false;
+		local use_ipv6 = v6_supported and configmanager.get("*", "use_ipv6") ~= false;
+
+		local function trim_dns_name(n)
+			return (n:gsub("%.$", ""));
+		end
+
+		local unknown_addresses = set.new();
+
+		for jid, host_options in enabled_hosts() do
+			local all_targets_ok, some_targets_ok = true, false;
+			local node, host = jid_split(jid);
+
+			local modules, component_module = modulemanager.get_modules_for_host(host);
+			if component_module then
+				modules:add(component_module);
+			end
+
+			local is_component = not not host_options.component_module;
+			print("Checking DNS for "..(is_component and "component" or "host").." "..jid.."...");
+			if node then
+				print("Only the domain part ("..host..") is used in DNS.")
+			end
+			local target_hosts = set.new();
+			if modules:contains("c2s") then
+				local res = dns.lookup("_xmpp-client._tcp."..idna.to_ascii(host)..".", "SRV");
+				if res and #res > 0 then
+					for _, record in ipairs(res) do
+						if record.srv.target == "." then -- TODO is this an error if mod_c2s is enabled?
+							print("    'xmpp-client' service disabled by pointing to '.'"); -- FIXME Explain better what this is
+							break;
+						end
+						local target = trim_dns_name(record.srv.target);
+						target_hosts:add(target);
+						if not c2s_ports:contains(record.srv.port) then
+							print("    SRV target "..target.." contains unknown client port: "..record.srv.port);
+						end
+					end
+				else
+					if c2s_srv_required then
+						print("    No _xmpp-client SRV record found for "..host..", but it looks like you need one.");
+						all_targets_ok = false;
+					else
+						target_hosts:add(host);
+					end
+				end
+			end
+			if modules:contains("c2s") and c2s_tls_srv_required then
+				local res = dns.lookup("_xmpps-client._tcp."..idna.to_ascii(host)..".", "SRV");
+				if res and #res > 0 then
+					for _, record in ipairs(res) do
+						if record.srv.target == "." then -- TODO is this an error if mod_c2s is enabled?
+							print("    'xmpps-client' service disabled by pointing to '.'"); -- FIXME Explain better what this is
+							break;
+						end
+						local target = trim_dns_name(record.srv.target);
+						target_hosts:add(target);
+						if not c2s_tls_ports:contains(record.srv.port) then
+							print("    SRV target "..target.." contains unknown Direct TLS client port: "..record.srv.port);
+						end
+					end
+				else
+					print("    No _xmpps-client SRV record found for "..host..", but it looks like you need one.");
+					all_targets_ok = false;
+				end
+			end
+			if modules:contains("s2s") then
+				local res = dns.lookup("_xmpp-server._tcp."..idna.to_ascii(host)..".", "SRV");
+				if res and #res > 0 then
+					for _, record in ipairs(res) do
+						if record.srv.target == "." then -- TODO Is this an error if mod_s2s is enabled?
+							print("    'xmpp-server' service disabled by pointing to '.'"); -- FIXME Explain better what this is
+							break;
+						end
+						local target = trim_dns_name(record.srv.target);
+						target_hosts:add(target);
+						if not s2s_ports:contains(record.srv.port) then
+							print("    SRV target "..target.." contains unknown server port: "..record.srv.port);
+						end
+					end
+				else
+					if s2s_srv_required then
+						print("    No _xmpp-server SRV record found for "..host..", but it looks like you need one.");
+						all_targets_ok = false;
+					else
+						target_hosts:add(host);
+					end
+				end
+			end
+			if modules:contains("s2s") and s2s_tls_srv_required then
+				local res = dns.lookup("_xmpps-server._tcp."..idna.to_ascii(host)..".", "SRV");
+				if res and #res > 0 then
+					for _, record in ipairs(res) do
+						if record.srv.target == "." then -- TODO is this an error if mod_s2s is enabled?
+							print("    'xmpps-server' service disabled by pointing to '.'"); -- FIXME Explain better what this is
+							break;
+						end
+						local target = trim_dns_name(record.srv.target);
+						target_hosts:add(target);
+						if not s2s_tls_ports:contains(record.srv.port) then
+							print("    SRV target "..target.." contains unknown Direct TLS server port: "..record.srv.port);
+						end
+					end
+				else
+					print("    No _xmpps-server SRV record found for "..host..", but it looks like you need one.");
+					all_targets_ok = false;
+				end
+			end
+			if target_hosts:empty() then
+				target_hosts:add(host);
+			end
+
+			if target_hosts:contains("localhost") then
+				print("    Target 'localhost' cannot be accessed from other servers");
+				target_hosts:remove("localhost");
+			end
+
+			local function check_address(target)
+				local A, AAAA = dns.lookup(idna.to_ascii(target), "A"), dns.lookup(idna.to_ascii(target), "AAAA");
+				local prob = {};
+				if use_ipv4 and not (A and #A > 0) then table.insert(prob, "A"); end
+				if use_ipv6 and not (AAAA and #AAAA > 0) then table.insert(prob, "AAAA"); end
+				return prob;
+			end
+
+			if modules:contains("proxy65") then
+				local proxy65_target = configmanager.get(host, "proxy65_address") or host;
+				if type(proxy65_target) == "string" then
+					local prob = check_address(proxy65_target);
+					if #prob > 0 then
+						print("    File transfer proxy "..proxy65_target.." has no "..table.concat(prob, "/")
+						.." record. Create one or set 'proxy65_address' to the correct host/IP.");
+					end
+				else
+					print("    proxy65_address for "..host.." should be set to a string, unable to perform DNS check");
+				end
+			end
+
+			local known_http_modules = set.new { "bosh"; "http_files"; "http_file_share"; "http_openmetrics"; "websocket" };
+			local function contains_match(hayset, needle)
+				for member in hayset do if member:find(needle) then return true end end
+			end
+
+			if modules:contains("http") or not set.intersection(modules, known_http_modules):empty()
+				or contains_match(modules, "^http_") or contains_match(modules, "_web$") then
+
+				local http_host = configmanager.get(host, "http_host") or host;
+				local http_internal_host = http_host;
+				local http_url = configmanager.get(host, "http_external_url");
+				if http_url then
+					local url_parse = require "socket.url".parse;
+					local external_url_parts = url_parse(http_url);
+					if external_url_parts then
+						http_host = external_url_parts.host;
+					else
+						print("    The 'http_external_url' setting is not a valid URL");
+					end
+				end
+
+				local prob = check_address(http_host);
+				if #prob > 1 then
+					print("    HTTP service " .. http_host .. " has no " .. table.concat(prob, "/") .. " record. Create one or change "
+									.. (http_url and "'http_external_url'" or "'http_host'").." to the correct host.");
+				end
+
+				if http_host ~= http_internal_host then
+					print("    Ensure the reverse proxy sets the HTTP Host header to '" .. http_internal_host .. "'");
+				end
+			end
+
+			if not use_ipv4 and not use_ipv6 then
+				print("    Both IPv6 and IPv4 are disabled, Prosody will not listen on any ports");
+				print("    nor be able to connect to any remote servers.");
+				all_targets_ok = false;
+			end
+
+			for target_host in target_hosts do
+				local host_ok_v4, host_ok_v6;
+				do
+					local res = dns.lookup(idna.to_ascii(target_host), "A");
+					if res then
+						for _, record in ipairs(res) do
+							if external_addresses:contains(record.a) then
+								some_targets_ok = true;
+								host_ok_v4 = true;
+							elseif internal_addresses:contains(record.a) then
+								host_ok_v4 = true;
+								some_targets_ok = true;
+								print("    "..target_host.." A record points to internal address, external connections might fail");
+							else
+								print("    "..target_host.." A record points to unknown address "..record.a);
+								unknown_addresses:add(record.a);
+								all_targets_ok = false;
+							end
+						end
+					end
+				end
+				do
+					local res = dns.lookup(idna.to_ascii(target_host), "AAAA");
+					if res then
+						for _, record in ipairs(res) do
+							if external_addresses:contains(record.aaaa) then
+								some_targets_ok = true;
+								host_ok_v6 = true;
+							elseif internal_addresses:contains(record.aaaa) then
+								host_ok_v6 = true;
+								some_targets_ok = true;
+								print("    "..target_host.." AAAA record points to internal address, external connections might fail");
+							else
+								print("    "..target_host.." AAAA record points to unknown address "..record.aaaa);
+								unknown_addresses:add(record.aaaa);
+								all_targets_ok = false;
+							end
+						end
+					end
+				end
+
+				if host_ok_v4 and not use_ipv4 then
+					print("    Host "..target_host.." does seem to resolve to this server but IPv4 has been disabled");
+					all_targets_ok = false;
+				end
+
+				if host_ok_v6 and not use_ipv6 then
+					print("    Host "..target_host.." does seem to resolve to this server but IPv6 has been disabled");
+					all_targets_ok = false;
+				end
+
+				local bad_protos = {}
+				if use_ipv4 and not host_ok_v4 then
+					table.insert(bad_protos, "IPv4");
+				end
+				if use_ipv6 and not host_ok_v6 then
+					table.insert(bad_protos, "IPv6");
+				end
+				if #bad_protos > 0 then
+					print("    Host "..target_host.." does not seem to resolve to this server ("..table.concat(bad_protos, "/")..")");
+				end
+				if host_ok_v6 and not v6_supported then
+					print("    Host "..target_host.." has AAAA records, but your version of LuaSocket does not support IPv6.");
+					print("      Please see https://prosody.im/doc/ipv6 for more information.");
+				elseif host_ok_v6 and not use_ipv6 then
+					print("    Host "..target_host.." has AAAA records, but IPv6 is disabled.");
+					-- TODO Tell them to drop the AAAA records or enable IPv6?
+					print("      Please see https://prosody.im/doc/ipv6 for more information.");
+				end
+			end
+			if not all_targets_ok then
+				print("    "..(some_targets_ok and "Only some" or "No").." targets for "..host.." appear to resolve to this server.");
+				if is_component then
+					print("    DNS records are necessary if you want users on other servers to access this component.");
+				end
+				problem_hosts:add(host);
+			end
+			print("");
+		end
+		if not problem_hosts:empty() then
+			if not unknown_addresses:empty() then
+				print("");
+				print("Some of your DNS records point to unknown IP addresses. This may be expected if your server");
+				print("is behind a NAT or proxy. The unrecognized addresses were:");
+				print("");
+				print("    Unrecognized: "..tostring(unknown_addresses));
+				print("");
+				print("The addresses we found on this system are:");
+				print("");
+				print("    Internal: "..tostring(internal_addresses));
+				print("    External: "..tostring(external_addresses));
+			end
+			print("");
+			print("For more information about DNS configuration please see https://prosody.im/doc/dns");
+			print("");
+			ok = false;
+		end
+	end
+	if not what or what == "certs" then
+		local cert_ok;
+		print"Checking certificates..."
+		local x509_verify_identity = require"util.x509".verify_identity;
+		local create_context = require "core.certmanager".create_context;
+		local ssl = dependencies.softreq"ssl";
+		-- local datetime_parse = require"util.datetime".parse_x509;
+		local load_cert = ssl and ssl.loadcertificate;
+		-- or ssl.cert_from_pem
+		if not ssl then
+			print("LuaSec not available, can't perform certificate checks")
+			if what == "certs" then cert_ok = false end
+		elseif not load_cert then
+			print("This version of LuaSec (" .. ssl._VERSION .. ") does not support certificate checking");
+			cert_ok = false
+		else
+			for host in it.filter(skip_bare_jid_hosts, enabled_hosts()) do
+				print("Checking certificate for "..host);
+				-- First, let's find out what certificate this host uses.
+				local host_ssl_config = configmanager.rawget(host, "ssl")
+					or configmanager.rawget(host:match("%.(.*)"), "ssl");
+				local global_ssl_config = configmanager.rawget("*", "ssl");
+				local ok, err, ssl_config = create_context(host, "server", host_ssl_config, global_ssl_config);
+				if not ok then
+					print("  Error: "..err);
+					cert_ok = false
+				elseif not ssl_config.certificate then
+					print("  No 'certificate' found for "..host)
+					cert_ok = false
+				elseif not ssl_config.key then
+					print("  No 'key' found for "..host)
+					cert_ok = false
+				else
+					local key, err = io.open(ssl_config.key); -- Permissions check only
+					if not key then
+						print("    Could not open "..ssl_config.key..": "..err);
+						cert_ok = false
+					else
+						key:close();
+					end
+					local cert_fh, err = io.open(ssl_config.certificate); -- Load the file.
+					if not cert_fh then
+						print("    Could not open "..ssl_config.certificate..": "..err);
+						cert_ok = false
+					else
+						print("  Certificate: "..ssl_config.certificate)
+						local cert = load_cert(cert_fh:read"*a"); cert_fh:close();
+						if not cert:validat(os.time()) then
+							print("    Certificate has expired.")
+							cert_ok = false
+						elseif not cert:validat(os.time() + 86400) then
+							print("    Certificate expires within one day.")
+							cert_ok = false
+						elseif not cert:validat(os.time() + 86400*7) then
+							print("    Certificate expires within one week.")
+						elseif not cert:validat(os.time() + 86400*31) then
+							print("    Certificate expires within one month.")
+						end
+						if configmanager.get(host, "component_module") == nil
+							and not x509_verify_identity(host, "_xmpp-client", cert) then
+							print("    Not valid for client connections to "..host..".")
+							cert_ok = false
+						end
+						if (not (configmanager.get(host, "anonymous_login")
+							or configmanager.get(host, "authentication") == "anonymous"))
+							and not x509_verify_identity(host, "_xmpp-server", cert) then
+							print("    Not valid for server-to-server connections to "..host..".")
+							cert_ok = false
+						end
+					end
+				end
+			end
+		end
+		if cert_ok == false then
+			print("")
+			print("For more information about certificates please see https://prosody.im/doc/certificates");
+			ok = false
+		end
+		print("")
+	end
+	-- intentionally not doing this by default
+	if what == "connectivity" then
+		local _, prosody_is_running = is_prosody_running();
+		if configmanager.get("*", "pidfile") and not prosody_is_running then
+			print("Prosody does not appear to be running, which is required for this test.");
+			print("Start it and then try again.");
+			return 1;
+		end
+
+		local checker = "observe.jabber.network";
+		local probe_instance;
+		local probe_modules = {
+			["xmpp-client"] = "c2s_normal_auth";
+			["xmpp-server"] = "s2s_normal";
+			["xmpps-client"] = nil; -- TODO
+			["xmpps-server"] = nil; -- TODO
+		};
+		local probe_settings = configmanager.get("*", "connectivity_probe");
+		if type(probe_settings) == "string" then
+			probe_instance = probe_settings;
+		elseif type(probe_settings) == "table" and type(probe_settings.url) == "string" then
+			probe_instance = probe_settings.url;
+			if type(probe_settings.modules) == "table" then
+				probe_modules = probe_settings.modules;
+			end
+		elseif probe_settings ~= nil then
+			print("The 'connectivity_probe' setting not understood.");
+			print("Expected an URL or a table with 'url' and 'modules' fields");
+			print("See https://prosody.im/doc/prosodyctl#check for more information."); -- FIXME
+			return 1;
+		end
+
+		local check_api;
+		if probe_instance then
+			local parsed_url = socket_url.parse(probe_instance);
+			if not parsed_url then
+				print(("'connectivity_probe' is not a valid URL: %q"):format(probe_instance));
+				print("Set it to the URL of an XMPP Blackbox Exporter instance and try again");
+				return 1;
+			end
+			checker = parsed_url.host;
+
+			function check_api(protocol, host)
+				local target = socket_url.build({scheme="xmpp",path=host});
+				local probe_module = probe_modules[protocol];
+				if not probe_module then
+					return nil, "Checking protocol '"..protocol.."' is currently unsupported";
+				end
+				return check_probe(probe_instance, probe_module, target);
+			end
+		else
+			check_api = check_ojn;
+		end
+
+		for host in it.filter(skip_bare_jid_hosts, enabled_hosts()) do
+			local modules, component_module = modulemanager.get_modules_for_host(host);
+			if component_module then
+				modules:add(component_module)
+			end
+
+			print("Checking external connectivity for "..host.." via "..checker)
+			local function check_connectivity(protocol)
+				local success, err = check_api(protocol, host);
+				if not success and err ~= nil then
+					print(("  %s: Failed to request check at API: %s"):format(protocol, err))
+				elseif success then
+					print(("  %s: Works"):format(protocol))
+				else
+					print(("  %s: Check service failed to establish (secure) connection"):format(protocol))
+					ok = false
+				end
+			end
+
+			if modules:contains("c2s") then
+				check_connectivity("xmpp-client")
+				if configmanager.get("*", "c2s_direct_tls_ports") then
+					check_connectivity("xmpps-client");
+				end
+			end
+
+			if modules:contains("s2s") then
+				check_connectivity("xmpp-server")
+				if configmanager.get("*", "s2s_direct_tls_ports") then
+					check_connectivity("xmpps-server");
+				end
+			end
+
+			print()
+		end
+		print("Note: The connectivity check only checks the reachability of the domain.")
+		print("Note: It does not ensure that the check actually reaches this specific prosody instance.")
+	end
+
+	if not what or what == "turn" then
+		local turn_enabled_hosts = {};
+		local turn_services = {};
+
+		for host in enabled_hosts() do
+			local has_external_turn = modulemanager.get_modules_for_host(host):contains("turn_external");
+			if has_external_turn then
+				table.insert(turn_enabled_hosts, host);
+				local turn_host = configmanager.get(host, "turn_external_host") or host;
+				local turn_port = configmanager.get(host, "turn_external_port") or 3478;
+				local turn_secret = configmanager.get(host, "turn_external_secret");
+				if not turn_secret then
+					print("Error: Your configuration is missing a turn_external_secret for "..host);
+					print("Error: TURN will not be advertised for this host.");
+					ok = false;
+				else
+					local turn_id = ("%s:%d"):format(turn_host, turn_port);
+					if turn_services[turn_id] and turn_services[turn_id].secret ~= turn_secret then
+						print("Error: Your configuration contains multiple differing secrets");
+						print("       for the TURN service at "..turn_id.." - we will only test one.");
+					elseif not turn_services[turn_id] then
+						turn_services[turn_id] = {
+							host = turn_host;
+							port = turn_port;
+							secret = turn_secret;
+						};
+					end
+				end
+			end
+		end
+
+		if what == "turn" then
+			local count = it.count(pairs(turn_services));
+			if count == 0 then
+				print("Error: Unable to find any TURN services configured. Enable mod_turn_external!");
+				ok = false;
+			else
+				print("Identified "..tostring(count).." TURN services.");
+				print("");
+			end
+		end
+
+		for turn_id, turn_service in pairs(turn_services) do
+			print("Testing TURN service "..turn_id.."...");
+
+			local result = check_turn_service(turn_service, opts.ping);
+			if #result.warnings > 0 then
+				print(("%d warnings:\n"):format(#result.warnings));
+				print("    "..table.concat(result.warnings, "\n    "));
+				print("");
+			end
+
+			if opts.verbose then
+				if result.external_ip then
+					print(("External IP: %s"):format(result.external_ip.address));
+				end
+				if result.relayed_addresses then
+					for i, relayed_address in ipairs(result.relayed_addresses) do
+						print(("Relayed address %d: %s:%d"):format(i, relayed_address.address, relayed_address.port));
+					end
+				end
+				if result.external_ip_pong then
+					print(("TURN external address: %s:%d"):format(result.external_ip_pong.address, result.external_ip_pong.port));
+				end
+			end
+
+			if result.error then
+				print("Error: "..result.error.."\n");
+				ok = false;
+			else
+				print("Success!\n");
+			end
+		end
+	end
+
+	if not ok then
+		print("Problems found, see above.");
+	else
+		print("All checks passed, congratulations!");
+	end
+	return ok and 0 or 2;
+end
+
+return {
+	check = check;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/prosodyctl/shell.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,148 @@
+local config = require "core.configmanager";
+local server = require "net.server";
+local st = require "util.stanza";
+local path = require "util.paths";
+local parse_args = require "util.argparse".parse;
+local unpack = table.unpack or _G.unpack;
+
+local have_readline, readline = pcall(require, "readline");
+
+local adminstream = require "util.adminstream";
+
+if have_readline then
+	readline.set_readline_name("prosody");
+	readline.set_options({
+			histfile = path.join(prosody.paths.data, ".shell_history");
+			ignoredups = true;
+		});
+end
+
+local function read_line(prompt_string)
+	if have_readline then
+		return readline.readline(prompt_string);
+	else
+		io.write(prompt_string);
+		return io.read("*line");
+	end
+end
+
+local function send_line(client, line)
+	client.send(st.stanza("repl-input"):text(line));
+end
+
+local function repl(client)
+	local line = read_line(client.prompt_string or "prosody> ");
+	if not line or line == "quit" or line == "exit" or line == "bye" then
+		if not line then
+			print("");
+		end
+		if have_readline then
+			readline.save_history();
+		end
+		os.exit();
+	end
+	send_line(client, line);
+end
+
+local function printbanner()
+	local banner = config.get("*", "console_banner");
+	if banner then return print(banner); end
+	print([[
+                     ____                \   /     _
+                    |  _ \ _ __ ___  ___  _-_   __| |_   _
+                    | |_) | '__/ _ \/ __|/ _ \ / _` | | | |
+                    |  __/| | | (_) \__ \ |_| | (_| | |_| |
+                    |_|   |_|  \___/|___/\___/ \__,_|\__, |
+                    A study in simplicity            |___/
+
+]]);
+	print("Welcome to the Prosody administration console. For a list of commands, type: help");
+	print("You may find more help on using this console in our online documentation at ");
+	print("https://prosody.im/doc/console\n");
+end
+
+local function start(arg) --luacheck: ignore 212/arg
+	local client = adminstream.client();
+	local opts, err, where = parse_args(arg);
+
+	if not opts then
+		if err == "param-not-found" then
+			print("Unknown command-line option: "..tostring(where));
+		elseif err == "missing-value" then
+			print("Expected a value to follow command-line option: "..where);
+		end
+		os.exit(1);
+	end
+
+	if arg[1] then
+		if arg[2] then
+			-- prosodyctl shell module reload foo bar.com --> module:reload("foo", "bar.com")
+			-- COMPAT Lua 5.1 doesn't have the separator argument to string.rep
+			arg[1] = string.format("%s:%s("..string.rep("%q, ", #arg-2):sub(1, -3)..")", unpack(arg));
+		end
+
+		client.events.add_handler("connected", function()
+			client.send(st.stanza("repl-input"):text(arg[1]));
+			return true;
+		end, 1);
+
+		local errors = 0; -- TODO This is weird, but works for now.
+		client.events.add_handler("received", function(stanza)
+			if stanza.name == "repl-output" or stanza.name == "repl-result" then
+				if stanza.attr.type == "error" then
+					errors = errors + 1;
+					io.stderr:write(stanza:get_text(), "\n");
+				else
+					print(stanza:get_text());
+				end
+			end
+			if stanza.name == "repl-result" then
+				os.exit(errors);
+			end
+			return true;
+		end, 1);
+	end
+
+	client.events.add_handler("connected", function ()
+		if not opts.quiet then
+			printbanner();
+		end
+		repl(client);
+	end);
+
+	client.events.add_handler("disconnected", function ()
+		print("--- session closed ---");
+		os.exit();
+	end);
+
+	client.events.add_handler("received", function (stanza)
+		if stanza.name == "repl-output" or stanza.name == "repl-result" then
+			local result_prefix = stanza.attr.type == "error" and "!" or "|";
+			print(result_prefix.." "..stanza:get_text());
+		end
+		if stanza.name == "repl-result" then
+			repl(client);
+		end
+	end);
+
+	client.prompt_string = config.get("*", "admin_shell_prompt");
+
+	local socket_path = path.resolve_relative_path(prosody.paths.data, opts.socket or config.get("*", "admin_socket") or "prosody.sock");
+	local conn = adminstream.connection(socket_path, client.listeners);
+	local ok, err = conn:connect();
+	if not ok then
+		if err == "no unix socket support" then
+			print("** LuaSocket unix socket support not available or incompatible, ensure your");
+			print("** version is up to date.");
+		else
+			print("** Unable to connect to server - is it running? Is mod_admin_shell enabled?");
+			print("** Connection error: "..err);
+		end
+		os.exit(1);
+	end
+	server.loop();
+end
+
+return {
+	shell = start;
+};
--- a/util/pubsub.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/pubsub.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,9 +1,11 @@
 local events = require "util.events";
 local cache = require "util.cache";
+local errors = require "util.error";
 
 local service_mt = {};
 
 local default_config = {
+	max_items = 256;
 	itemstore = function (config, _) return cache.new(config["max_items"]) end;
 	broadcaster = function () end;
 	subscriber_filter = function (subs) return subs end;
@@ -131,10 +133,11 @@
 local default_config_mt = { __index = default_config };
 
 local default_node_config = {
-	["persist_items"] = false;
+	["persist_items"] = true;
 	["max_items"] = 20;
 	["access_model"] = "open";
 	["publish_model"] = "publishers";
+	["send_last_published_item"] = "never";
 };
 local default_node_config_mt = { __index = default_node_config };
 
@@ -176,8 +179,11 @@
 	-- Load nodes from storage, if we have a store and it supports iterating over stored items
 	if config.nodestore and config.nodestore.users then
 		for node_name in config.nodestore:users() do
-			service.nodes[node_name] = load_node_from_store(service, node_name);
-			service.data[node_name] = config.itemstore(service.nodes[node_name].config, node_name);
+			local node = load_node_from_store(service, node_name);
+			service.nodes[node_name] = node;
+			if node.config.persist_items then
+				service.data[node_name] = config.itemstore(service.nodes[node_name].config, node_name);
+			end
 
 			for jid in pairs(service.nodes[node_name].subscribers) do
 				local normal_jid = service.config.normalize_jid(jid);
@@ -280,7 +286,8 @@
 	node_obj.affiliations[jid] = affiliation;
 
 	if self.config.nodestore then
-		local ok, err = save_node_to_store(self, node_obj);
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
 			node_obj.affiliations[jid] = old_affiliation;
 			return ok, "internal-server-error";
@@ -344,7 +351,8 @@
 	end
 
 	if self.config.nodestore then
-		local ok, err = save_node_to_store(self, node_obj);
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
 			node_obj.subscribers[jid] = old_subscription;
 			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
@@ -396,7 +404,8 @@
 	end
 
 	if self.config.nodestore then
-		local ok, err = save_node_to_store(self, node_obj);
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
 			node_obj.subscribers[jid] = old_subscription;
 			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
@@ -454,14 +463,18 @@
 	};
 
 	if self.config.nodestore then
-		local ok, err = save_node_to_store(self, self.nodes[node]);
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, self.nodes[node]); -- luacheck: ignore 211/err
 		if not ok then
 			self.nodes[node] = nil;
 			return ok, "internal-server-error";
 		end
 	end
 
-	self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+	if config.persist_items then
+		self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+	end
+
 	self.events.fire_event("node-created", { service = self, node = node, actor = actor });
 	if actor ~= true then
 		local ok, err = self:set_affiliation(node, true, actor, "owner");
@@ -511,7 +524,7 @@
 	end
 	for config_field, value in pairs(required_config) do
 		if node_config[config_field] ~= value then
-			return false;
+			return false, config_field;
 		end
 	end
 	return true;
@@ -547,23 +560,28 @@
 		node_obj = self.nodes[node];
 	elseif requested_config and not requested_config._defaults_only then
 		-- Check that node has the requested config before we publish
-		if not check_preconditions(node_obj.config, requested_config) then
-			return false, "precondition-not-met";
+		local ok, field = check_preconditions(node_obj.config, requested_config);
+		if not ok then
+			local err = errors.new({
+				type = "cancel", condition = "conflict", text = "Field does not match: "..field;
+			});
+			err.pubsub_condition = "precondition-not-met";
+			return false, err;
 		end
 	end
 	if not self.config.itemcheck(item) then
 		return nil, "invalid-item";
 	end
-	local node_data = self.data[node];
-	if not node_data then
-		-- FIXME how is this possible?  #1657
-		return nil, "internal-server-error";
+	if node_obj.config.persist_items then
+		if not self.data[node] then
+			self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+		end
+		local ok = self.data[node]:set(id, item);
+		if not ok then
+			return nil, "internal-server-error";
+		end
+		if type(ok) == "string" then id = ok; end
 	end
-	local ok = node_data:set(id, item);
-	if not ok then
-		return nil, "internal-server-error";
-	end
-	if type(ok) == "string" then id = ok; end
 	local event_data = { service = self, node = node, actor = actor, id = id, item = item };
 	self.events.fire_event("item-published/"..node, event_data);
 	self.events.fire_event("item-published", event_data);
@@ -583,12 +601,17 @@
 	end
 	--
 	local node_obj = self.nodes[node];
-	if (not node_obj) or (not self.data[node]:get(id)) then
+	if not node_obj then
 		return false, "item-not-found";
 	end
-	local ok = self.data[node]:set(id, nil);
-	if not ok then
-		return nil, "internal-server-error";
+	if self.data[node] then
+		if not self.data[node]:get(id) then
+			return false, "item-not-found";
+		end
+		local ok = self.data[node]:set(id, nil);
+		if not ok then
+			return nil, "internal-server-error";
+		end
 	end
 	self.events.fire_event("item-retracted", { service = self, node = node, actor = actor, id = id });
 	if retract then
@@ -607,10 +630,12 @@
 	if not node_obj then
 		return false, "item-not-found";
 	end
-	if self.data[node] and self.data[node].clear then
-		self.data[node]:clear()
-	else
-		self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+	if self.data[node] then
+		if self.data[node].clear then
+			self.data[node]:clear()
+		else
+			self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+		end
 	end
 	self.events.fire_event("node-purged", { service = self, node = node, actor = actor });
 	if notify then
@@ -619,7 +644,7 @@
 	return true
 end
 
-function service:get_items(node, actor, ids) --> (true, { id, [id] = node }) or (false, err)
+function service:get_items(node, actor, ids, resultspec) --> (true, { id, [id] = node }) or (false, err)
 	-- Access checking
 	if not self:may(node, actor, "get_items") then
 		return false, "forbidden";
@@ -629,22 +654,31 @@
 	if not node_obj then
 		return false, "item-not-found";
 	end
+	if not self.data[node] then
+		-- Disabled rather than unsupported, but close enough.
+		return false, "persistent-items-unsupported";
+	end
 	if type(ids) == "string" then -- COMPAT see #1305
 		ids = { ids };
 	end
 	local data = {};
+	local limit = resultspec and resultspec.max;
 	if ids then
 		for _, key in ipairs(ids) do
 			local value = self.data[node]:get(key);
 			if value then
 				data[#data+1] = key;
 				data[key] = value;
+				-- Limits and ids seem like a problematic combination.
+				if limit and #data >= limit then break end
 			end
 		end
 	else
 		for key, value in self.data[node]:items() do
 			data[#data+1] = key;
 			data[key] = value;
+			if limit and #data >= limit then break
+			end
 		end
 	end
 	return true, data;
@@ -662,6 +696,11 @@
 		return false, "item-not-found";
 	end
 
+	if not self.data[node] then
+		-- FIXME Should this be a success or failure?
+		return true, nil;
+	end
+
 	-- Returns success, id, item
 	return true, self.data[node]:head();
 end
@@ -772,7 +811,8 @@
 	node_obj.config = new_config;
 
 	if self.config.nodestore then
-		local ok, err = save_node_to_store(self, node_obj);
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
 			node_obj.config = old_config;
 			return ok, "internal-server-error";
@@ -792,9 +832,22 @@
 	end
 
 	if old_config["persist_items"] ~= node_obj.config["persist_items"] then
-		self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+		if node_obj.config["persist_items"] then
+			self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+		elseif self.data[node] then
+			if self.data[node].clear then
+				self.data[node]:clear()
+			end
+			self.data[node] = nil;
+		end
 	elseif old_config["max_items"] ~= node_obj.config["max_items"] then
-		self.data[node]:resize(self.nodes[node].config["max_items"]);
+		if self.data[node] then
+			local max_items = self.nodes[node].config["max_items"];
+			if max_items == "max" then
+				max_items = self.config.max_items;
+			end
+			self.data[node]:resize(max_items);
+		end
 	end
 
 	return true;
--- a/util/queue.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/queue.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -59,18 +59,20 @@
 			return true;
 		end;
 		items = function (self)
-			--luacheck: ignore 431/t
-			return function (t, pos)
-				if pos >= t:count() then
+			return function (_, pos)
+				if pos >= items then
 					return nil;
 				end
 				local read_pos = tail + pos;
-				if read_pos > t.size then
+				if read_pos > self.size then
 					read_pos = (read_pos%size);
 				end
-				return pos+1, t._items[read_pos];
+				return pos+1, t[read_pos];
 			end, self, 0;
 		end;
+		consume = function (self)
+			return self.pop, self;
+		end;
 	};
 end
 
--- a/util/random.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/random.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -7,7 +7,7 @@
 --
 
 local ok, crand = pcall(require, "util.crand");
-if ok then return crand; end
+if ok and pcall(crand.bytes, 1) then return crand; end
 
 local urandom, urandom_err = io.open("/dev/urandom", "r");
 
--- a/util/rsm.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/rsm.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -10,10 +10,15 @@
 --
 
 local stanza = require"util.stanza".stanza;
-local tostring, tonumber = tostring, tonumber;
+local tonumber = tonumber;
+local s_format = string.format;
 local type = type;
 local pairs = pairs;
 
+local function inttostr(n)
+	return s_format("%d", n);
+end
+
 local xmlns_rsm = 'http://jabber.org/protocol/rsm';
 
 local element_parsers = {};
@@ -45,22 +50,31 @@
 local element_generators = setmetatable({
 	first = function(st, data)
 		if type(data) == "table" then
-			st:tag("first", { index = data.index }):text(data[1]):up();
+			st:tag("first", { index = inttostr(data.index) }):text(data[1]):up();
 		else
-			st:tag("first"):text(tostring(data)):up();
+			st:text_tag("first", data);
 		end
 	end;
 	before = function(st, data)
 		if data == true then
 			st:tag("before"):up();
 		else
-			st:tag("before"):text(tostring(data)):up();
+			st:text_tag("before", data);
 		end
-	end
+	end;
+	max = function (st, data)
+		st:text_tag("max", inttostr(data));
+	end;
+	index = function (st, data)
+		st:text_tag("index", inttostr(data));
+	end;
+	count = function (st, data)
+		st:text_tag("count", inttostr(data));
+	end;
 }, {
 	__index = function(_, name)
 		return function(st, data)
-			st:tag(name):text(tostring(data)):up();
+			st:text_tag(name, data);
 		end
 	end;
 });
--- a/util/sasl.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/sasl.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -27,7 +27,7 @@
 
 state = false : disabled
 state = true : enabled
-state = nil : non-existant
+state = nil : non-existent
 
 Channel Binding:
 
@@ -47,7 +47,7 @@
 local backend_mechanism = {};
 local mechanism_channelbindings = {};
 
--- register a new SASL mechanims
+-- register a new SASL mechanisms
 local function registerMechanism(name, backends, f, cb_backends)
 	assert(type(name) == "string", "Parameter name MUST be a string.");
 	assert(type(backends) == "string" or type(backends) == "table", "Parameter backends MUST be either a string or a table.");
@@ -97,7 +97,7 @@
 	return new(self.realm, self.profile)
 end
 
--- get a list of possible SASL mechanims to use
+-- get a list of possible SASL mechanisms to use
 function method:mechanisms()
 	local current_mechs = {};
 	for mech, _ in pairs(self.mechs) do
@@ -134,7 +134,6 @@
 
 -- load the mechanisms
 require "util.sasl.plain"     .init(registerMechanism);
-require "util.sasl.digest-md5".init(registerMechanism);
 require "util.sasl.anonymous" .init(registerMechanism);
 require "util.sasl.scram"     .init(registerMechanism);
 require "util.sasl.external"  .init(registerMechanism);
--- a/util/sasl/digest-md5.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,251 +0,0 @@
--- sasl.lua v0.4
--- Copyright (C) 2008-2010 Tobias Markmann
---
---    All rights reserved.
---
---    Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
---
---        * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
---        * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
---        * Neither the name of Tobias Markmann nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
---
---    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-local tostring = tostring;
-local type = type;
-
-local s_gmatch = string.gmatch;
-local s_match = string.match;
-local t_concat = table.concat;
-local t_insert = table.insert;
-local to_byte, to_char = string.byte, string.char;
-
-local md5 = require "util.hashes".md5;
-local log = require "util.logger".init("sasl");
-local generate_uuid = require "util.uuid".generate;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-
-local _ENV = nil;
--- luacheck: std none
-
---=========================
---SASL DIGEST-MD5 according to RFC 2831
-
---[[
-Supported Authentication Backends
-
-digest_md5:
-	function(username, domain, realm, encoding) -- domain and realm are usually the same; for some broken
-												-- implementations it's not
-		return digesthash, state;
-	end
-
-digest_md5_test:
-	function(username, domain, realm, encoding, digesthash)
-		return true or false, state;
-	end
-]]
-
-local function digest(self, message)
-	--TODO complete support for authzid
-
-	local function serialize(message)
-		local data = ""
-
-		-- testing all possible values
-		if message["realm"] then data = data..[[realm="]]..message.realm..[[",]] end
-		if message["nonce"] then data = data..[[nonce="]]..message.nonce..[[",]] end
-		if message["qop"] then data = data..[[qop="]]..message.qop..[[",]] end
-		if message["charset"] then data = data..[[charset=]]..message.charset.."," end
-		if message["algorithm"] then data = data..[[algorithm=]]..message.algorithm.."," end
-		if message["rspauth"] then data = data..[[rspauth=]]..message.rspauth.."," end
-		data = data:gsub(",$", "")
-		return data
-	end
-
-	local function utf8tolatin1ifpossible(passwd)
-		local i = 1;
-		while i <= #passwd do
-			local passwd_i = to_byte(passwd:sub(i, i));
-			if passwd_i > 0x7F then
-				if passwd_i < 0xC0 or passwd_i > 0xC3 then
-					return passwd;
-				end
-				i = i + 1;
-				passwd_i = to_byte(passwd:sub(i, i));
-				if passwd_i < 0x80 or passwd_i > 0xBF then
-					return passwd;
-				end
-			end
-			i = i + 1;
-		end
-
-		local p = {};
-		local j = 0;
-		i = 1;
-		while (i <= #passwd) do
-			local passwd_i = to_byte(passwd:sub(i, i));
-			if passwd_i > 0x7F then
-				i = i + 1;
-				local passwd_i_1 = to_byte(passwd:sub(i, i));
-				t_insert(p, to_char(passwd_i%4*64 + passwd_i_1%64)); -- I'm so clever
-			else
-				t_insert(p, to_char(passwd_i));
-			end
-			i = i + 1;
-		end
-		return t_concat(p);
-	end
-	local function latin1toutf8(str)
-		local p = {};
-		for ch in s_gmatch(str, ".") do
-			ch = to_byte(ch);
-			if (ch < 0x80) then
-				t_insert(p, to_char(ch));
-			elseif (ch < 0xC0) then
-				t_insert(p, to_char(0xC2, ch));
-			else
-				t_insert(p, to_char(0xC3, ch - 64));
-			end
-		end
-		return t_concat(p);
-	end
-	local function parse(data)
-		local message = {}
-		-- COMPAT: %z in the pattern to work around jwchat bug (sends "charset=utf-8\0")
-		for k, v in s_gmatch(data, [[([%w%-]+)="?([^",%z]*)"?,?]]) do -- FIXME The hacky regex makes me shudder
-			message[k] = v;
-		end
-		return message;
-	end
-
-	if not self.nonce then
-		self.nonce = generate_uuid();
-		self.step = 0;
-		self.nonce_count = {};
-	end
-
-	self.step = self.step + 1;
-	if (self.step == 1) then
-		local challenge = serialize({	nonce = self.nonce,
-										qop = "auth",
-										charset = "utf-8",
-										algorithm = "md5-sess",
-										realm = self.realm});
-		return "challenge", challenge;
-	elseif (self.step == 2) then
-		local response = parse(message);
-		-- check for replay attack
-		if response["nc"] then
-			if self.nonce_count[response["nc"]] then return "failure", "not-authorized" end
-		end
-
-		-- check for username, it's REQUIRED by RFC 2831
-		local username = response["username"];
-		local _nodeprep = self.profile.nodeprep;
-		if username and _nodeprep ~= false then
-			username = (_nodeprep or nodeprep)(username); -- FIXME charset
-		end
-		if not username or username == "" then
-			return "failure", "malformed-request";
-		end
-		self.username = username;
-
-		-- check for nonce, ...
-		if not response["nonce"] then
-			return "failure", "malformed-request";
-		else
-			-- check if it's the right nonce
-			if response["nonce"] ~= tostring(self.nonce) then return "failure", "malformed-request" end
-		end
-
-		if not response["cnonce"] then return "failure", "malformed-request", "Missing entry for cnonce in SASL message." end
-		if not response["qop"] then response["qop"] = "auth" end
-
-		if response["realm"] == nil or response["realm"] == "" then
-			response["realm"] = "";
-		elseif response["realm"] ~= self.realm then
-			return "failure", "not-authorized", "Incorrect realm value";
-		end
-
-		local decoder;
-		if response["charset"] == nil then
-			decoder = utf8tolatin1ifpossible;
-		elseif response["charset"] ~= "utf-8" then
-			return "failure", "incorrect-encoding", "The client's response uses "..response["charset"].." for encoding with isn't supported by sasl.lua. Supported encodings are latin or utf-8.";
-		end
-
-		local domain = "";
-		local protocol = "";
-		if response["digest-uri"] then
-			protocol, domain = response["digest-uri"]:match("(%w+)/(.*)$");
-			if protocol == nil or domain == nil then return "failure", "malformed-request" end
-		else
-			return "failure", "malformed-request", "Missing entry for digest-uri in SASL message."
-		end
-
-		--TODO maybe realm support
-		local Y, state;
-		if self.profile.plain then
-			local password, state = self.profile.plain(self, response["username"], self.realm)
-			if state == nil then return "failure", "not-authorized"
-			elseif state == false then return "failure", "account-disabled" end
-			Y = md5(response["username"]..":"..response["realm"]..":"..password);
-		elseif self.profile["digest-md5"] then
-			Y, state = self.profile["digest-md5"](self, response["username"], self.realm, response["realm"], response["charset"])
-			if state == nil then return "failure", "not-authorized"
-			elseif state == false then return "failure", "account-disabled" end
-		elseif self.profile["digest-md5-test"] then
-			-- TODO
-		end
-		--local password_encoding, Y = self.credentials_handler("DIGEST-MD5", response["username"], self.realm, response["realm"], decoder);
-		--if Y == nil then return "failure", "not-authorized"
-		--elseif Y == false then return "failure", "account-disabled" end
-		local A1 = "";
-		if response.authzid then
-			if response.authzid == self.username or response.authzid == self.username.."@"..self.realm then
-				-- COMPAT
-				log("warn", "Client is violating RFC 3920 (section 6.1, point 7).");
-				A1 = Y..":"..response["nonce"]..":"..response["cnonce"]..":"..response.authzid;
-			else
-				return "failure", "invalid-authzid";
-			end
-		else
-			A1 = Y..":"..response["nonce"]..":"..response["cnonce"];
-		end
-		local A2 = "AUTHENTICATE:"..protocol.."/"..domain;
-
-		local HA1 = md5(A1, true);
-		local HA2 = md5(A2, true);
-
-		local KD = HA1..":"..response["nonce"]..":"..response["nc"]..":"..response["cnonce"]..":"..response["qop"]..":"..HA2;
-		local response_value = md5(KD, true);
-
-		if response_value == response["response"] then
-			-- calculate rspauth
-			A2 = ":"..protocol.."/"..domain;
-
-			HA1 = md5(A1, true);
-			HA2 = md5(A2, true);
-
-			KD = HA1..":"..response["nonce"]..":"..response["nc"]..":"..response["cnonce"]..":"..response["qop"]..":"..HA2
-			local rspauth = md5(KD, true);
-			self.authenticated = true;
-			--TODO: considering sending the rspauth in a success node for saving one roundtrip; allowed according to http://tools.ietf.org/html/draft-saintandre-rfc3920bis-09#section-7.3.6
-			return "challenge", serialize({rspauth = rspauth});
-		else
-			return "failure", "not-authorized", "The response provided by the client doesn't match the one we calculated."
-		end
-	elseif self.step == 3 then
-		if self.authenticated ~= nil then return "success"
-		else return "failure", "malformed-request" end
-	end
-end
-
-local function init(registerMechanism)
-	registerMechanism("DIGEST-MD5", {"plain"}, digest);
-end
-
-return {
-	init = init;
-}
--- a/util/sasl/scram.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/sasl/scram.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -14,16 +14,12 @@
 local s_match = string.match;
 local type = type
 local base64 = require "util.encodings".base64;
-local hmac_sha1 = require "util.hashes".hmac_sha1;
-local sha1 = require "util.hashes".sha1;
-local Hi = require "util.hashes".scram_Hi_sha1;
+local hashes = require "util.hashes";
 local generate_uuid = require "util.uuid".generate;
 local saslprep = require "util.encodings".stringprep.saslprep;
 local nodeprep = require "util.encodings".stringprep.nodeprep;
 local log = require "util.logger".init("sasl");
-local t_concat = table.concat;
-local char = string.char;
-local byte = string.byte;
+local	binaryXOR = require "util.strbitop".sxor;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -45,33 +41,7 @@
 'tls-unique' according to RFC 5929
 ]]
 
-local default_i = 4096
-
-local xor_map = {
-	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,1,0,3,2,5,4,7,6,9,8,11,10,
-	13,12,15,14,2,3,0,1,6,7,4,5,10,11,8,9,14,15,12,13,3,2,1,0,7,6,5,
-	4,11,10,9,8,15,14,13,12,4,5,6,7,0,1,2,3,12,13,14,15,8,9,10,11,5,
-	4,7,6,1,0,3,2,13,12,15,14,9,8,11,10,6,7,4,5,2,3,0,1,14,15,12,13,
-	10,11,8,9,7,6,5,4,3,2,1,0,15,14,13,12,11,10,9,8,8,9,10,11,12,13,
-	14,15,0,1,2,3,4,5,6,7,9,8,11,10,13,12,15,14,1,0,3,2,5,4,7,6,10,
-	11,8,9,14,15,12,13,2,3,0,1,6,7,4,5,11,10,9,8,15,14,13,12,3,2,1,
-	0,7,6,5,4,12,13,14,15,8,9,10,11,4,5,6,7,0,1,2,3,13,12,15,14,9,8,
-	11,10,5,4,7,6,1,0,3,2,14,15,12,13,10,11,8,9,6,7,4,5,2,3,0,1,15,
-	14,13,12,11,10,9,8,7,6,5,4,3,2,1,0,
-};
-
-local result = {};
-local function binaryXOR( a, b )
-	for i=1, #a do
-		local x, y = byte(a, i), byte(b, i);
-		local lowx, lowy = x % 16, y % 16;
-		local hix, hiy = (x - lowx) / 16, (y - lowy) / 16;
-		local lowr, hir = xor_map[lowx * 16 + lowy + 1], xor_map[hix * 16 + hiy + 1];
-		local r = hir * 16 + lowr;
-		result[i] = char(r)
-	end
-	return t_concat(result);
-end
+local default_i = 10000
 
 local function validate_username(username, _nodeprep)
 	-- check for forbidden char sequences
@@ -99,24 +69,26 @@
 	return hashname:lower():gsub("-", "_");
 end
 
-local function getAuthenticationDatabaseSHA1(password, salt, iteration_count)
-	if type(password) ~= "string" or type(salt) ~= "string" or type(iteration_count) ~= "number" then
-		return false, "inappropriate argument types"
-	end
-	if iteration_count < 4096 then
-		log("warn", "Iteration count < 4096 which is the suggested minimum according to RFC 5802.")
+local function get_scram_hasher(H, HMAC, Hi)
+	return function (password, salt, iteration_count)
+		if type(password) ~= "string" or type(salt) ~= "string" or type(iteration_count) ~= "number" then
+			return false, "inappropriate argument types"
+		end
+		if iteration_count < 4096 then
+			log("warn", "Iteration count < 4096 which is the suggested minimum according to RFC 5802.")
+		end
+		password = saslprep(password);
+		if not password then
+			return false, "password fails SASLprep";
+		end
+		local salted_password = Hi(password, salt, iteration_count);
+		local stored_key = H(HMAC(salted_password, "Client Key"))
+		local server_key = HMAC(salted_password, "Server Key");
+		return true, stored_key, server_key
 	end
-	password = saslprep(password);
-	if not password then
-		return false, "password fails SASLprep";
-	end
-	local salted_password = Hi(password, salt, iteration_count);
-	local stored_key = sha1(hmac_sha1(salted_password, "Client Key"))
-	local server_key = hmac_sha1(salted_password, "Server Key");
-	return true, stored_key, server_key
 end
 
-local function scram_gen(hash_name, H_f, HMAC_f)
+local function scram_gen(hash_name, H_f, HMAC_f, get_auth_db, expect_cb)
 	local profile_name = "scram_" .. hashprep(hash_name);
 	local function scram_hash(self, message)
 		local support_channel_binding = false;
@@ -129,6 +101,7 @@
 			local client_first_message = message;
 
 			-- TODO: fail if authzid is provided, since we don't support them yet
+			-- luacheck: ignore 211/authzid
 			local gs2_header, gs2_cbind_flag, gs2_cbind_name, authzid, client_first_message_bare, username, clientnonce
 				= s_match(client_first_message, "^(([pny])=?([^,]*),([^,]*),)(m?=?[^,]*,?n=([^,]*),r=([^,]*),?.*)$");
 
@@ -144,6 +117,10 @@
 
 			if gs2_cbind_flag == "n" then
 				-- "n" -> client doesn't support channel binding.
+				if expect_cb then
+					log("debug", "Client unexpectedly doesn't support channel binding");
+					-- XXX Is it sensible to abort if the client starts -PLUS but doesn't use channel binding?
+				end
 				support_channel_binding = false;
 			end
 
@@ -181,7 +158,7 @@
 				iteration_count = default_i;
 
 				local succ;
-				succ, stored_key, server_key = getAuthenticationDatabaseSHA1(password, salt, iteration_count);
+				succ, stored_key, server_key = get_auth_db(password, salt, iteration_count);
 				if not succ then
 					log("error", "Generating authentication database failed. Reason: %s", stored_key);
 					return "failure", "temporary-auth-failure";
@@ -194,11 +171,11 @@
 			end
 
 			local nonce = clientnonce .. generate_uuid();
-			local server_first_message = "r="..nonce..",s="..base64.encode(salt)..",i="..iteration_count;
+			local server_first_message = ("r=%s,s=%s,i=%d"):format(nonce, base64.encode(salt), iteration_count);
 			self.state = {
 				gs2_header = gs2_header;
 				gs2_cbind_name = gs2_cbind_name;
-				username = username;
+				username = self.username; -- Reference property instead of local, in case it was modified by the profile
 				nonce = nonce;
 
 				server_key = server_key;
@@ -251,22 +228,28 @@
 	return scram_hash;
 end
 
+local auth_db_getters = {}
 local function init(registerMechanism)
-	local function registerSCRAMMechanism(hash_name, hash, hmac_hash)
+	local function registerSCRAMMechanism(hash_name, hash, hmac_hash, pbkdf2)
+		local get_auth_db = get_scram_hasher(hash, hmac_hash, pbkdf2);
+		auth_db_getters[hash_name] = get_auth_db;
 		registerMechanism("SCRAM-"..hash_name,
 			{"plain", "scram_"..(hashprep(hash_name))},
-			scram_gen(hash_name:lower(), hash, hmac_hash));
+			scram_gen(hash_name:lower(), hash, hmac_hash, get_auth_db));
 
 		-- register channel binding equivalent
 		registerMechanism("SCRAM-"..hash_name.."-PLUS",
 			{"plain", "scram_"..(hashprep(hash_name))},
-			scram_gen(hash_name:lower(), hash, hmac_hash), {"tls-unique"});
+			scram_gen(hash_name:lower(), hash, hmac_hash, get_auth_db, true), {"tls-unique"});
 	end
 
-	registerSCRAMMechanism("SHA-1", sha1, hmac_sha1);
+	registerSCRAMMechanism("SHA-1", hashes.sha1, hashes.hmac_sha1, hashes.pbkdf2_hmac_sha1);
+	registerSCRAMMechanism("SHA-256", hashes.sha256, hashes.hmac_sha256, hashes.pbkdf2_hmac_sha256);
 end
 
 return {
-	getAuthenticationDatabaseSHA1 = getAuthenticationDatabaseSHA1;
+	get_hash = get_scram_hasher;
+	hashers = auth_db_getters;
+	getAuthenticationDatabaseSHA1 = get_scram_hasher(hashes.sha1, hashes.hmac_sha1, hashes.pbkdf2_hmac_sha1); -- COMPAT
 	init = init;
 }
--- a/util/sasl_cyrus.lua	Mon Dec 12 07:03:31 2022 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,169 +0,0 @@
--- sasl.lua v0.4
--- Copyright (C) 2008-2009 Tobias Markmann
---
---    All rights reserved.
---
---    Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
---
---        * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
---        * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
---        * Neither the name of Tobias Markmann nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
---
---    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-local cyrussasl = require "cyrussasl";
-local log = require "util.logger".init("sasl_cyrus");
-
-local setmetatable = setmetatable
-
-local pcall = pcall
-local s_match, s_gmatch = string.match, string.gmatch
-
-local sasl_errstring = {
-	-- SASL result codes --
-	[1]   = "another step is needed in authentication";
-	[0]   = "successful result";
-	[-1]  = "generic failure";
-	[-2]  = "memory shortage failure";
-	[-3]  = "overflowed buffer";
-	[-4]  = "mechanism not supported";
-	[-5]  = "bad protocol / cancel";
-	[-6]  = "can't request info until later in exchange";
-	[-7]  = "invalid parameter supplied";
-	[-8]  = "transient failure (e.g., weak key)";
-	[-9]  = "integrity check failed";
-	[-12] = "SASL library not initialized";
-
-	-- client only codes --
-	[2]   = "needs user interaction";
-	[-10] = "server failed mutual authentication step";
-	[-11] = "mechanism doesn't support requested feature";
-
-	-- server only codes --
-	[-13] = "authentication failure";
-	[-14] = "authorization failure";
-	[-15] = "mechanism too weak for this user";
-	[-16] = "encryption needed to use mechanism";
-	[-17] = "One time use of a plaintext password will enable requested mechanism for user";
-	[-18] = "passphrase expired, has to be reset";
-	[-19] = "account disabled";
-	[-20] = "user not found";
-	[-23] = "version mismatch with plug-in";
-	[-24] = "remote authentication server unavailable";
-	[-26] = "user exists, but no verifier for user";
-
-	-- codes for password setting --
-	[-21] = "passphrase locked";
-	[-22] = "requested change was not needed";
-	[-27] = "passphrase is too weak for security policy";
-	[-28] = "user supplied passwords not permitted";
-};
-setmetatable(sasl_errstring, { __index = function() return "undefined error!" end });
-
-local _ENV = nil;
--- luacheck: std none
-
-local method = {};
-method.__index = method;
-local initialized = false;
-
-local function init(service_name)
-	if not initialized then
-		local st, errmsg = pcall(cyrussasl.server_init, service_name);
-		if st then
-			initialized = true;
-		else
-			log("error", "Failed to initialize Cyrus SASL: %s", errmsg);
-		end
-	end
-end
-
--- create a new SASL object which can be used to authenticate clients
--- host_fqdn may be nil in which case gethostname() gives the value.
---      For GSSAPI, this determines the hostname in the service ticket (after
---      reverse DNS canonicalization, only if [libdefaults] rdns = true which
---      is the default).
-local function new(realm, service_name, app_name, host_fqdn)
-
-	init(app_name or service_name);
-
-	local st, ret = pcall(cyrussasl.server_new, service_name, host_fqdn, realm, nil, nil)
-	if not st then
-		log("error", "Creating SASL server connection failed: %s", ret);
-		return nil;
-	end
-
-	local sasl_i = { realm = realm, service_name = service_name, cyrus = ret };
-
-	if cyrussasl.set_canon_cb then
-		local c14n_cb = function (user)
-			local node = s_match(user, "^([^@]+)");
-			log("debug", "Canonicalizing username %s to %s", user, node)
-			return node
-		end
-		cyrussasl.set_canon_cb(sasl_i.cyrus, c14n_cb);
-	end
-
-	cyrussasl.setssf(sasl_i.cyrus, 0, 0xffffffff)
-	local mechanisms = {};
-	local cyrus_mechs = cyrussasl.listmech(sasl_i.cyrus, nil, "", " ", "");
-	for w in s_gmatch(cyrus_mechs, "[^ ]+") do
-		mechanisms[w] = true;
-	end
-	sasl_i.mechs = mechanisms;
-	return setmetatable(sasl_i, method);
-end
-
--- get a fresh clone with the same realm and service name
-function method:clean_clone()
-	return new(self.realm, self.service_name)
-end
-
--- get a list of possible SASL mechanims to use
-function method:mechanisms()
-	return self.mechs;
-end
-
--- select a mechanism to use
-function method:select(mechanism)
-	if not self.selected and self.mechs[mechanism] then
-		self.selected = mechanism;
-		return true;
-	end
-end
-
--- feed new messages to process into the library
-function method:process(message)
-	local err;
-	local data;
-
-	if not self.first_step_done then
-		err, data = cyrussasl.server_start(self.cyrus, self.selected, message or "")
-		self.first_step_done = true;
-	else
-		err, data = cyrussasl.server_step(self.cyrus, message or "")
-	end
-
-	self.username = cyrussasl.get_username(self.cyrus)
-
-	if (err == 0) then -- SASL_OK
-		if self.require_provisioning and not self.require_provisioning(self.username) then
-			return "failure", "not-authorized", "User authenticated successfully, but not provisioned for XMPP";
-		end
-		return "success", data
-	elseif (err == 1) then -- SASL_CONTINUE
-		return "challenge", data
-	elseif (err == -4) then -- SASL_NOMECH
-		log("debug", "SASL mechanism not available from remote end")
-		return "failure", "invalid-mechanism", "SASL mechanism not available"
-	elseif (err == -13) then -- SASL_BADAUTH
-		return "failure", "not-authorized", sasl_errstring[err];
-	else
-		log("debug", "Got SASL error condition %d: %s", err, sasl_errstring[err]);
-		return "failure", "undefined-condition", sasl_errstring[err];
-	end
-end
-
-return {
-	new = new;
-};
--- a/util/serialization.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/serialization.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -16,22 +16,18 @@
 local s_match = string.match;
 local t_concat = table.concat;
 
+local to_hex = require "util.hex".to;
+
 local pcall = pcall;
 local envload = require"util.envload".envload;
 
 local pos_inf, neg_inf = math.huge, -math.huge;
--- luacheck: ignore 143/math
 local m_type = math.type or function (n)
 	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
 end;
 
-local char_to_hex = {};
-for i = 0,255 do
-	char_to_hex[s_char(i)] = s_format("%02x", i);
-end
-
-local function to_hex(s)
-	return (s_gsub(s, ".", char_to_hex));
+local function rawpairs(t)
+	return next, t, nil;
 end
 
 local function fatal_error(obj, why)
@@ -123,6 +119,7 @@
 	local freeze = opt.freeze;
 	local maxdepth = opt.maxdepth or 127;
 	local multirefs = opt.multiref;
+	local table_pairs = opt.table_iterator or rawpairs;
 
 	-- serialize one table, recursively
 	-- t - table being serialized
@@ -153,6 +150,10 @@
 
 				if type(fr) == "function" then
 					t = fr(t);
+					if type(t) == "string" then
+						o[l], l = t, l + 1;
+						return l;
+					end
 					if type(tag) == "string" then
 						o[l], l = tag, l + 1;
 					end
@@ -164,7 +165,9 @@
 		local indent = s_rep(indentwith, d);
 		local numkey = 1;
 		local ktyp, vtyp;
-		for k,v in next,t do
+		local had_items = false;
+		for k,v in table_pairs(t) do
+			had_items = true;
 			o[l], l = itemstart, l + 1;
 			o[l], l = indent, l + 1;
 			ktyp, vtyp = type(k), type(v);
@@ -195,14 +198,10 @@
 			else
 				o[l], l = ser(v), l + 1;
 			end
-			-- last item?
-			if next(t, k) ~= nil then
-				o[l], l = itemsep, l + 1;
-			else
-				o[l], l = itemlast, l + 1;
-			end
+			o[l], l = itemsep, l + 1;
 		end
-		if next(t) ~= nil then
+		if had_items then
+			o[l - 1] = itemlast;
 			o[l], l = s_rep(indentwith, d-1), l + 1;
 		end
 		o[l], l = tend, l +1;
--- a/util/session.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/session.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -4,12 +4,13 @@
 local function new_session(typ)
 	local session = {
 		type = typ .. "_unauthed";
+		base_type = typ;
 	};
 	return session;
 end
 
 local function set_id(session)
-	local id = session.type .. tostring(session):match("%x+$"):lower();
+	local id = session.base_type .. tostring(session):match("%x+$"):lower();
 	session.id = id;
 	return session;
 end
@@ -30,7 +31,7 @@
 	local conn = session.conn;
 	if not conn then
 		function session.send(data)
-			session.log("debug", "Discarding data sent to unconnected session: %s", tostring(data));
+			session.log("debug", "Discarding data sent to unconnected session: %s", data);
 			return false;
 		end
 		return session;
@@ -46,7 +47,7 @@
 			if t then
 				local ret, err = w(conn, t);
 				if not ret then
-					session.log("debug", "Error writing to connection: %s", tostring(err));
+					session.log("debug", "Error writing to connection: %s", err);
 					return false, err;
 				end
 			end
--- a/util/set.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/set.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -6,8 +6,9 @@
 -- COPYING file in the source package for more information.
 --
 
-local ipairs, pairs, getmetatable, setmetatable, next, tostring =
-      ipairs, pairs, getmetatable, setmetatable, next, tostring;
+local ipairs, pairs, setmetatable, next, tostring =
+      ipairs, pairs, setmetatable, next, tostring;
+local getmetatable = getmetatable;
 local t_concat = table.concat;
 
 local _ENV = nil;
@@ -51,6 +52,15 @@
 		return items[item];
 	end
 
+	function set:contains_set(other_set)
+		for item in other_set do
+			if not self:contains(item) then
+				return false;
+			end
+		end
+		return true;
+	end
+
 	function set:items()
 		return next, items;
 	end
@@ -151,6 +161,11 @@
 	return new_set;
 end
 function set_mt.__eq(set1, set2)
+	if getmetatable(set1) ~= set_mt or getmetatable(set2) ~= set_mt then
+		-- Lua 5.3+ calls this if both operands are tables, even if metatables differ
+		return false;
+	end
+
 	set1, set2 = set1._items, set2._items;
 	for item in pairs(set1) do
 		if not set2[item] then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/smqueue.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,56 @@
+local queue = require("util.queue");
+
+local lib = { smqueue = {} }
+
+local smqueue = lib.smqueue;
+
+function smqueue:push(v)
+	self._head = self._head + 1;
+
+	assert(self._queue:push(v));
+end
+
+function smqueue:ack(h)
+	if h < self._tail then
+		return nil, "tail"
+	elseif h > self._head then
+		return nil, "head"
+	end
+
+	local acked = {};
+	self._tail = h;
+	local expect = self._head - self._tail;
+	while expect < self._queue:count() do
+		local v = self._queue:pop();
+		if not v then return nil, "pop" end
+		table.insert(acked, v);
+	end
+	return acked
+end
+
+function smqueue:count_unacked() return self._head - self._tail end
+
+function smqueue:count_acked() return self._tail end
+
+function smqueue:resumable() return self._queue:count() >= (self._head - self._tail) end
+
+function smqueue:resume() return self._queue:items() end
+
+function smqueue:consume() return self._queue:consume() end
+
+function smqueue:table()
+	local t = {};
+	for i, v in self:resume() do t[i] = v; end
+	return t
+end
+
+local function freeze(q) return { head = q._head; tail = q._tail } end
+
+local queue_mt = { __name = "smqueue"; __index = smqueue; __len = smqueue.count_unacked; __freeze = freeze }
+
+function lib.new(size)
+	assert(size > 0);
+	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt)
+end
+
+return lib
--- a/util/sql.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/sql.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -201,31 +201,31 @@
 		if not ok then return ok, err; end
 	end
 	--assert(not self.__transaction, "Recursive transactions not allowed");
-	log("debug", "SQL transaction begin [%s]", tostring(func));
+	log("debug", "SQL transaction begin [%s]", func);
 	self.__transaction = true;
 	local success, a, b, c = xpcall(func, handleerr, ...);
 	self.__transaction = nil;
 	if success then
-		log("debug", "SQL transaction success [%s]", tostring(func));
+		log("debug", "SQL transaction success [%s]", func);
 		local ok, err = self.conn:commit();
 		-- LuaDBI doesn't actually return an error message here, just a boolean
 		if not ok then return ok, err or "commit failed"; end
 		return success, a, b, c;
 	else
-		log("debug", "SQL transaction failure [%s]: %s", tostring(func), a.err);
+		log("debug", "SQL transaction failure [%s]: %s", func, a.err);
 		if self.conn then self.conn:rollback(); end
 		return success, a.err;
 	end
 end
 function engine:transaction(...)
-	local ok, ret = self:_transaction(...);
+	local ok, ret, b, c = self:_transaction(...);
 	if not ok then
 		local conn = self.conn;
 		if not conn or not conn:ping() then
 			log("debug", "Database connection was closed. Will reconnect and retry.");
 			self.conn = nil;
-			log("debug", "Retrying SQL transaction [%s]", tostring((...)));
-			ok, ret = self:_transaction(...);
+			log("debug", "Retrying SQL transaction [%s]", (...));
+			ok, ret, b, c = self:_transaction(...);
 			log("debug", "SQL transaction retry %s", ok and "succeeded" or "failed");
 		else
 			log("debug", "SQL connection is up, so not retrying");
@@ -234,7 +234,7 @@
 			log("error", "Error in SQL transaction: %s", ret);
 		end
 	end
-	return ok, ret;
+	return ok, ret, b, c;
 end
 function engine:_create_index(index)
 	local sql = "CREATE INDEX \""..index.name.."\" ON \""..index.table.."\" (";
@@ -335,6 +335,9 @@
 		local ok, actual_charset = self:transaction(function ()
 			return self:select"SHOW SESSION VARIABLES LIKE 'character_set_client'";
 		end);
+		if not ok then
+			return false, "Failed to detect connection encoding";
+		end
 		local charset_ok = true;
 		for row in actual_charset do
 			if row[2] ~= charset then
--- a/util/sslconfig.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/sslconfig.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -67,6 +67,9 @@
 -- Curve list too
 finalisers.curveslist = finalisers.ciphers;
 
+-- TLS 1.3 ciphers
+finalisers.ciphersuites = finalisers.ciphers;
+
 -- protocol = "x" should enable only that protocol
 -- protocol = "x+" should enable x and later versions
 
--- a/util/stanza.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/stanza.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -11,7 +11,6 @@
 local t_insert      =  table.insert;
 local t_remove      =  table.remove;
 local t_concat      =  table.concat;
-local s_format      = string.format;
 local s_match       =  string.match;
 local tostring      =      tostring;
 local setmetatable  =  setmetatable;
@@ -22,20 +21,10 @@
 local s_gsub        =   string.gsub;
 local s_sub         =    string.sub;
 local s_find        =   string.find;
-local os            =            os;
 
 local valid_utf8 = require "util.encodings".utf8.valid;
 
-local do_pretty_printing = not os.getenv("WINDIR");
-local getstyle, getstring;
-if do_pretty_printing then
-	local ok, termcolours = pcall(require, "util.termcolours");
-	if ok then
-		getstyle, getstring = termcolours.getstyle, termcolours.getstring;
-	else
-		do_pretty_printing = nil;
-	end
-end
+local do_pretty_printing, termcolours = pcall(require, "util.termcolours");
 
 local xmlns_stanzas = "urn:ietf:params:xml:ns:xmpp-stanzas";
 
@@ -80,16 +69,11 @@
 local function check_attr(attr)
 	if attr ~= nil then
 		if type(attr) ~= "table" then
-			error("invalid attributes, expected table got "..type(attr));
+			error("invalid attributes: expected table, got "..type(attr));
 		end
 		for k, v in pairs(attr) do
 			check_name(k, "attribute");
 			check_text(v, "attribute");
-			if type(v) ~= "string" then
-				error("invalid attribute value for '"..k.."': expected string, got "..type(v));
-			elseif not valid_utf8(v) then
-				error("invalid attribute value for '"..k.."': contains invalid utf8");
-			end
 		end
 	end
 end
@@ -110,7 +94,7 @@
 end
 
 function stanza_mt:body(text, attr)
-	return self:tag("body", attr):text(text);
+	return self:text_tag("body", text, attr);
 end
 
 function stanza_mt:text_tag(name, text, attr, namespaces)
@@ -140,6 +124,10 @@
 	return self;
 end
 
+function stanza_mt:at_top()
+	return self.last_add == nil or #self.last_add == 0
+end
+
 function stanza_mt:reset()
 	self.last_add = nil;
 	return self;
@@ -180,6 +168,7 @@
 			return child;
 		end
 	end
+	return nil;
 end
 
 function stanza_mt:get_child_text(name, xmlns)
@@ -194,12 +183,23 @@
 	for _, child in ipairs(self.tags) do
 		if child.name == name then return child; end
 	end
+	return nil;
 end
 
 function stanza_mt:child_with_ns(ns)
 	for _, child in ipairs(self.tags) do
 		if child.attr.xmlns == ns then return child; end
 	end
+	return nil;
+end
+
+function stanza_mt:get_child_with_attr(name, xmlns, attr_name, attr_value, normalize)
+	for tag in self:childtags(name, xmlns) do
+		if (normalize and normalize(tag.attr[attr_name]) or tag.attr[attr_name]) == attr_value then
+			return tag;
+		end
+	end
+	return nil;
 end
 
 function stanza_mt:children()
@@ -282,6 +282,34 @@
 	until not self
 end
 
+local function _clone(stanza, only_top)
+	local attr, tags = {}, {};
+	for k,v in pairs(stanza.attr) do attr[k] = v; end
+	local old_namespaces, namespaces = stanza.namespaces;
+	if old_namespaces then
+		namespaces = {};
+		for k,v in pairs(old_namespaces) do namespaces[k] = v; end
+	end
+	local new = { name = stanza.name, attr = attr, namespaces = namespaces, tags = tags };
+	if not only_top then
+		for i=1,#stanza do
+			local child = stanza[i];
+			if child.name then
+				child = _clone(child);
+				t_insert(tags, child);
+			end
+			t_insert(new, child);
+		end
+	end
+	return setmetatable(new, stanza_mt);
+end
+
+local function clone(stanza, only_top)
+	if not is_stanza(stanza) then
+		error("bad argument to clone: expected stanza, got "..type(stanza));
+	end
+	return _clone(stanza, only_top);
+end
 
 local escape_table = { ["'"] = "&apos;", ["\""] = "&quot;", ["<"] = "&lt;", [">"] = "&gt;", ["&"] = "&amp;" };
 local function xml_escape(str) return (s_gsub(str, "['&<>\"]", escape_table)); end
@@ -322,25 +350,23 @@
 end
 
 function stanza_mt.top_tag(t)
-	local attr_string = "";
-	if t.attr then
-		for k, v in pairs(t.attr) do if type(k) == "string" then attr_string = attr_string .. s_format(" %s='%s'", k, xml_escape(tostring(v))); end end
-	end
-	return s_format("<%s%s>", t.name, attr_string);
+	local top_tag_clone = clone(t, true);
+	return tostring(top_tag_clone):sub(1,-3)..">";
 end
 
 function stanza_mt.get_text(t)
 	if #t.tags == 0 then
 		return t_concat(t);
 	end
+	return nil;
 end
 
 function stanza_mt.get_error(stanza)
-	local error_type, condition, text;
+	local error_type, condition, text, extra_tag;
 
 	local error_tag = stanza:get_child("error");
 	if not error_tag then
-		return nil, nil, nil;
+		return nil, nil, nil, nil;
 	end
 	error_type = error_tag.attr.type;
 
@@ -351,12 +377,14 @@
 			elseif not condition then
 				condition = child.name;
 			end
-			if condition and text then
-				break;
-			end
+		else
+			extra_tag = child;
+		end
+		if condition and text and extra_tag then
+			break;
 		end
 	end
-	return error_type, condition or "undefined-condition", text;
+	return error_type, condition or "undefined-condition", text, extra_tag;
 end
 
 local function preserialize(stanza)
@@ -400,50 +428,32 @@
 	end
 end
 
-local function _clone(stanza)
-	local attr, tags = {}, {};
-	for k,v in pairs(stanza.attr) do attr[k] = v; end
-	local old_namespaces, namespaces = stanza.namespaces;
-	if old_namespaces then
-		namespaces = {};
-		for k,v in pairs(old_namespaces) do namespaces[k] = v; end
-	end
-	local new = { name = stanza.name, attr = attr, namespaces = namespaces, tags = tags };
-	for i=1,#stanza do
-		local child = stanza[i];
-		if child.name then
-			child = _clone(child);
-			t_insert(tags, child);
-		end
-		t_insert(new, child);
-	end
-	return setmetatable(new, stanza_mt);
-end
-
-local function clone(stanza)
-	if not is_stanza(stanza) then
-		error("bad argument to clone: expected stanza, got "..type(stanza));
-	end
-	return _clone(stanza);
-end
-
 local function message(attr, body)
 	if not body then
 		return new_stanza("message", attr);
 	else
-		return new_stanza("message", attr):tag("body"):text(body):up();
+		return new_stanza("message", attr):text_tag("body", body);
 	end
 end
 local function iq(attr)
-	if not (attr and attr.id) then
+	if not attr then
+		error("iq stanzas require id and type attributes");
+	end
+	if not attr.id then
 		error("iq stanzas require an id attribute");
 	end
+	if not attr.type then
+		error("iq stanzas require a type attribute");
+	end
 	return new_stanza("iq", attr);
 end
 
 local function reply(orig)
+	if not is_stanza(orig) then
+		error("bad argument to reply: expected stanza, got "..type(orig));
+	end
 	return new_stanza(orig.name,
-		orig.attr and {
+		{
 			to = orig.attr.from,
 			from = orig.attr.to,
 			id = orig.attr.id,
@@ -452,12 +462,37 @@
 end
 
 local xmpp_stanzas_attr = { xmlns = xmlns_stanzas };
-local function error_reply(orig, error_type, condition, error_message)
+local function error_reply(orig, error_type, condition, error_message, error_by)
+	if not is_stanza(orig) then
+		error("bad argument to error_reply: expected stanza, got "..type(orig));
+	elseif orig.attr.type == "error" then
+		error("bad argument to error_reply: got stanza of type error which must not be replied to");
+	end
 	local t = reply(orig);
 	t.attr.type = "error";
-	t:tag("error", {type = error_type}) --COMPAT: Some day xmlns:stanzas goes here
-	:tag(condition, xmpp_stanzas_attr):up();
-	if error_message then t:tag("text", xmpp_stanzas_attr):text(error_message):up(); end
+	local extra;
+	if type(error_type) == "table" then -- an util.error or similar object
+		if type(error_type.extra) == "table" then
+			extra = error_type.extra;
+		end
+		if type(error_type.context) == "table" and type(error_type.context.by) == "string" then error_by = error_type.context.by; end
+		error_type, condition, error_message = error_type.type, error_type.condition, error_type.text;
+	end
+	if t.attr.from == error_by then
+		error_by = nil;
+	end
+	t:tag("error", {type = error_type, by = error_by}) --COMPAT: Some day xmlns:stanzas goes here
+	:tag(condition, xmpp_stanzas_attr);
+	if extra and condition == "gone" and type(extra.uri) == "string" then
+		t:text(extra.uri);
+	end
+	t:up();
+	if error_message then t:text_tag("text", error_message, xmpp_stanzas_attr); end
+	if extra and is_stanza(extra.tag) then
+		t:add_child(extra.tag);
+	elseif extra and extra.namespace and extra.condition then
+		t:tag(extra.condition, { xmlns = extra.namespace }):up();
+	end
 	return t; -- stanza ready for adding app-specific errors
 end
 
@@ -465,39 +500,50 @@
 	return new_stanza("presence", attr);
 end
 
+local pretty;
 if do_pretty_printing then
-	local style_attrk = getstyle("yellow");
-	local style_attrv = getstyle("red");
-	local style_tagname = getstyle("red");
-	local style_punc = getstyle("magenta");
+	local getstyle, getstring = termcolours.getstyle, termcolours.getstring;
+
+	local blue1 = getstyle("1b3967");
+	local blue2 = getstyle("13b5ea");
+	local green1 = getstyle("439639");
+	local green2 = getstyle("a0ce67");
+	local orange1 = getstyle("d9541e");
+	local orange2 = getstyle("e96d1f");
+
+	local attr_replace = (
+		getstring(green2, "%1") .. -- attr name
+		getstring(green1, "%2") .. -- equal
+		getstring(orange1, "%3") .. -- quote
+		getstring(orange2, "%4") .. -- attr value
+		getstring(orange1, "%5") -- quote
+	);
 
-	local attr_format = " "..getstring(style_attrk, "%s")..getstring(style_punc, "=")..getstring(style_attrv, "'%s'");
-	local top_tag_format = getstring(style_punc, "<")..getstring(style_tagname, "%s").."%s"..getstring(style_punc, ">");
-	--local tag_format = getstring(style_punc, "<")..getstring(style_tagname, "%s").."%s"..getstring(style_punc, ">").."%s"..getstring(style_punc, "</")..getstring(style_tagname, "%s")..getstring(style_punc, ">");
-	local tag_format = top_tag_format.."%s"..getstring(style_punc, "</")..getstring(style_tagname, "%s")..getstring(style_punc, ">");
+	local text_replace = (
+		getstring(green1, "%1") .. -- &
+		getstring(green2, "%2") .. -- amp
+		getstring(green1, "%3") -- ;
+	);
+
+	function pretty(s)
+		-- Tag soup color
+		-- Outer gsub call takes each <tag>, applies colour to the brackets, the
+		-- tag name, then applies one inner gsub call to colour the attributes and
+		-- another for any text content.
+		return (s:gsub("(<[?/]?)([^ >/?]*)(.-)([?/]?>)([^<]*)", function(opening_bracket, tag_name, attrs, closing_bracket, content)
+			return getstring(blue1, opening_bracket)..getstring(blue2, tag_name)..
+				attrs:gsub("([^=]+)(=)([\"'])(.-)([\"'])", attr_replace) ..
+			getstring(blue1, closing_bracket) ..
+			content:gsub("(&#?)(%w+)(;)", text_replace);
+		end, 100));
+	end
+
 	function stanza_mt.pretty_print(t)
-		local children_text = "";
-		for _, child in ipairs(t) do
-			if type(child) == "string" then
-				children_text = children_text .. xml_escape(child);
-			else
-				children_text = children_text .. child:pretty_print();
-			end
-		end
-
-		local attr_string = "";
-		if t.attr then
-			for k, v in pairs(t.attr) do if type(k) == "string" then attr_string = attr_string .. s_format(attr_format, k, tostring(v)); end end
-		end
-		return s_format(tag_format, t.name, attr_string, children_text, t.name);
+		return pretty(tostring(t));
 	end
 
 	function stanza_mt.pretty_top_tag(t)
-		local attr_string = "";
-		if t.attr then
-			for k, v in pairs(t.attr) do if type(k) == "string" then attr_string = attr_string .. s_format(attr_format, k, tostring(v)); end end
-		end
-		return s_format(top_tag_format, t.name, attr_string);
+		return pretty(t:top_tag());
 	end
 else
 	-- Sorry, fresh out of colours for you guys ;)
@@ -505,6 +551,36 @@
 	stanza_mt.pretty_top_tag = stanza_mt.top_tag;
 end
 
+function stanza_mt.indent(t, level, indent)
+	if #t == 0 or (#t == 1 and type(t[1]) == "string") then
+		-- Empty nodes wouldn't have any indentation
+		-- Text-only nodes are preserved as to not alter the text content
+		-- Optimization: Skip clone of these since we don't alter them
+		return t;
+	end
+
+	indent = indent or "\t";
+	level = level or 1;
+	local tag = clone(t, true);
+
+	for child in t:children() do
+		if type(child) == "string" then
+			-- Already indented text would look weird but let's ignore that for now.
+			if child:find("%S") then
+				tag:text("\n" .. indent:rep(level));
+				tag:text(child);
+			end
+		elseif is_stanza(child) then
+			tag:text("\n" .. indent:rep(level));
+			tag:add_direct_child(child:indent(level+1, indent));
+		end
+	end
+	-- before the closing tag
+	tag:text("\n" .. indent:rep((level-1)));
+
+	return tag;
+end
+
 return {
 	stanza_mt = stanza_mt;
 	stanza = new_stanza;
@@ -518,4 +594,5 @@
 	error_reply = error_reply;
 	presence = presence;
 	xml_escape = xml_escape;
+	pretty_print = pretty;
 };
--- a/util/startup.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/startup.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -5,8 +5,10 @@
 local prosody = { events = require "util.events".new() };
 local logger = require "util.logger";
 local log = logger.init("startup");
+local parse_args = require "util.argparse".parse;
 
 local config = require "core.configmanager";
+local config_warnings;
 
 local dependencies = require "util.dependencies";
 
@@ -20,59 +22,45 @@
 	minor_threshold = 20, major_threshold = 50;
 };
 
-local short_params = { D = "daemonize", F = "no-daemonize" };
-local value_params = { config = true };
-
-function startup.parse_args()
-	local parsed_opts = {};
-	prosody.opts = parsed_opts;
+local arg_settigs = {
+	prosody = {
+		short_params = { D = "daemonize"; F = "no-daemonize", h = "help", ["?"] = "help" };
+		value_params = { config = true };
+	};
+	prosodyctl = {
+		short_params = { v = "verbose", h = "help", ["?"] = "help" };
+		value_params = { config = true };
+	};
+}
 
-	if #arg == 0 then
-		return;
-	end
-	while true do
-		local raw_param = arg[1];
-		if not raw_param then
-			break;
-		end
-
-		local prefix = raw_param:match("^%-%-?");
-		if not prefix then
-			break;
-		elseif prefix == "--" and raw_param == "--" then
-			table.remove(arg, 1);
-			break;
-		end
-		local param = table.remove(arg, 1):sub(#prefix+1);
-		if #param == 1 then
-			param = short_params[param];
+function startup.parse_args(profile)
+	local opts, err, where = parse_args(arg, arg_settigs[profile or prosody.process_type] or profile);
+	if not opts then
+		if err == "param-not-found" then
+			print("Unknown command-line option: "..tostring(where));
+			if prosody.process_type == "prosody" then
+				print("Perhaps you meant to use prosodyctl instead?");
+			end
+		elseif err == "missing-value" then
+			print("Expected a value to follow command-line option: "..where);
 		end
-
-		if not param then
-			print("Unknown command-line option: "..tostring(raw_param));
-			print("Perhaps you meant to use prosodyctl instead?");
-			os.exit(1);
+		os.exit(1);
+	end
+	if prosody.process_type == "prosody" then
+		if #arg > 0 then
+			print("Unrecognized option: "..arg[1]);
+			print("(Did you mean 'prosodyctl "..arg[1].."'?)");
+			print("");
 		end
-
-		local param_k, param_v;
-		if value_params[param] then
-			param_k, param_v = param, table.remove(arg, 1);
-			if not param_v then
-				print("Expected a value to follow command-line option: "..raw_param);
-				os.exit(1);
-			end
-		else
-			param_k, param_v = param:match("^([^=]+)=(.+)$");
-			if not param_k then
-				if param:match("^no%-") then
-					param_k, param_v = param:sub(4), false;
-				else
-					param_k, param_v = param, true;
-				end
-			end
+		if opts.help or #arg > 0 then
+			print("prosody [ -D | -F ] [ --config /path/to/prosody.cfg.lua ]");
+			print("  -D, --daemonize       Run in the background")
+			print("  -F, --no-daemonize    Run in the foreground")
+			print("  --config FILE         Specify config file")
+			os.exit(0);
 		end
-		parsed_opts[param_k] = param_v;
 	end
+	prosody.opts = opts;
 end
 
 function startup.read_config()
@@ -127,6 +115,8 @@
 		print("**************************");
 		print("");
 		os.exit(1);
+	elseif err and #err > 0 then
+		config_warnings = err;
 	end
 	prosody.config_loaded = true;
 end
@@ -142,6 +132,7 @@
 function startup.load_libraries()
 	-- Load socket framework
 	-- luacheck: ignore 111/server 111/socket
+	require "util.import"
 	socket = require "socket";
 	server = require "net.server"
 end
@@ -159,8 +150,13 @@
 	end);
 end
 
-function startup.log_dependency_warnings()
+function startup.log_startup_warnings()
 	dependencies.log_warnings();
+	if config_warnings then
+		for _, warning in ipairs(config_warnings) do
+			log("warn", "Configuration warning: %s", warning);
+		end
+	end
 end
 
 function startup.sanity_check()
@@ -229,8 +225,15 @@
 		end
 	end
 	function mt.__tostring(f)
-		local info = debug.getinfo(f);
-		return ("function(%s:%d)"):format(info.short_src:match("[^\\/]*$"), info.linedefined);
+		local info = debug.getinfo(f, "Su");
+		local n_params = info.nparams or 0;
+		for i = 1, n_params do
+			info[i] = debug.getlocal(f, i);
+		end
+		if info.isvararg then
+			info[n_params+1] = "...";
+		end
+		return ("function<%s:%d>(%s)"):format(info.short_src:match("[^\\/]*$"), info.linedefined, table.concat(info, ", "));
 	end
 	debug.setmetatable(function() end, mt);
 end
@@ -282,8 +285,8 @@
 
 function startup.setup_plugindir()
 	local custom_plugin_paths = config.get("*", "plugin_paths");
+	local path_sep = package.config:sub(3,3);
 	if custom_plugin_paths then
-		local path_sep = package.config:sub(3,3);
 		-- path1;path2;path3;defaultpath...
 		-- luacheck: ignore 111
 		CFG_PLUGINDIR = table.concat(custom_plugin_paths, path_sep)..path_sep..(CFG_PLUGINDIR or "plugins");
@@ -291,6 +294,17 @@
 	end
 end
 
+function startup.setup_plugin_install_path()
+	local installer_plugin_path = config.get("*", "installer_plugin_path") or "custom_plugins";
+	local path_sep = package.config:sub(3,3);
+	installer_plugin_path = config.resolve_relative_path(CFG_DATADIR or "data", installer_plugin_path);
+	require"util.paths".complement_lua_path(installer_plugin_path);
+	-- luacheck: ignore 111
+	CFG_PLUGINDIR = installer_plugin_path..path_sep..(CFG_PLUGINDIR or "plugins");
+	prosody.paths.installer = installer_plugin_path;
+	prosody.paths.plugins = CFG_PLUGINDIR;
+end
+
 function startup.chdir()
 	if prosody.installed then
 		local lfs = require "lfs";
@@ -312,9 +326,9 @@
 		local ok, level, err = config.load(prosody.config_file);
 		if not ok then
 			if level == "parser" then
-				log("error", "There was an error parsing the configuration file: %s", tostring(err));
+				log("error", "There was an error parsing the configuration file: %s", err);
 			elseif level == "file" then
-				log("error", "Couldn't read the config file when trying to reload: %s", tostring(err));
+				log("error", "Couldn't read the config file when trying to reload: %s", err);
 			end
 		else
 			prosody.events.fire_event("config-reloaded", {
@@ -340,13 +354,12 @@
 			reason = reason;
 			code = code;
 		});
-		server.setquitting(true);
+		prosody.main_thread:run(startup.shutdown);
 	end
 end
 
 function startup.load_secondary_libraries()
 	--- Load and initialise core modules
-	require "util.import"
 	require "util.xmppstream"
 	require "core.stanza_router"
 	require "core.statsmanager"
@@ -387,6 +400,22 @@
 	local https_client = config.get("*", "client_https_ssl")
 	http.default.options.sslctx = require "core.certmanager".create_context("client_https port 0", "client",
 		{ capath = config_ssl.capath, cafile = config_ssl.cafile, verify = "peer", }, https_client);
+	http.default.options.use_dane = config.get("*", "use_dane")
+end
+
+function startup.init_promise()
+	local promise = require "util.promise";
+
+	local timer = require "util.timer";
+	promise.set_nexttick(function(f) return timer.add_task(0, f); end);
+end
+
+function startup.init_async()
+	local async = require "util.async";
+
+	local timer = require "util.timer";
+	async.set_nexttick(function(f) return timer.add_task(0, f); end);
+	async.set_schedule_function(timer.add_task);
 end
 
 function startup.init_data_store()
@@ -448,7 +477,18 @@
 -- Override logging config (used by prosodyctl)
 function startup.force_console_logging()
 	original_logging_config = config.get("*", "log");
-	config.set("*", "log", { { levels = { min = os.getenv("PROSODYCTL_LOG_LEVEL") or "info" }, to = "console" } });
+	local log_level = os.getenv("PROSODYCTL_LOG_LEVEL");
+	if not log_level then
+		if prosody.opts.verbose then
+			log_level = "debug";
+		elseif prosody.opts.quiet then
+			log_level = "error";
+		elseif prosody.opts.silent then
+			config.set("*", "log", {}); -- ssssshush!
+			return
+		end
+	end
+	config.set("*", "log", { { levels = { min = log_level or "info" }, to = "console" } });
 end
 
 function startup.switch_user()
@@ -486,9 +526,9 @@
 			if not prosody.switched_user then
 				-- Boo!
 				print("Warning: Couldn't switch to Prosody user/group '"..tostring(desired_user).."'/'"..tostring(desired_group).."': "..tostring(err));
-			else
+			elseif prosody.config_file then
 				-- Make sure the Prosody user can read the config
-				local conf, err, errno = io.open(prosody.config_file);
+				local conf, err, errno = io.open(prosody.config_file); --luacheck: ignore 211/errno
 				if conf then
 					conf:close();
 				else
@@ -565,6 +605,10 @@
 	return true;
 end
 
+function startup.init_errors()
+	require "util.error".configure(config.get("*", "error_library") or {});
+end
+
 function startup.make_host(hostname)
 	return {
 		type = "local",
@@ -587,21 +631,44 @@
 	end
 end
 
+function startup.cleanup()
+	prosody.log("info", "Shutdown status: Cleaning up");
+	prosody.events.fire_event("server-cleanup");
+end
+
+function startup.shutdown()
+	prosody.log("info", "Shutting down...");
+	startup.cleanup();
+	prosody.events.fire_event("server-stopped");
+	prosody.log("info", "Shutdown complete");
+
+	prosody.log("debug", "Shutdown reason was: %s", prosody.shutdown_reason or "not specified");
+	prosody.log("debug", "Exiting with status code: %d", prosody.shutdown_code or 0);
+	server.setquitting(true);
+end
+
+function startup.exit()
+	os.exit(prosody.shutdown_code);
+end
+
 -- prosodyctl only
 function startup.prosodyctl()
+	prosody.process_type = "prosodyctl";
 	startup.parse_args();
 	startup.init_global_state();
 	startup.read_config();
 	startup.force_console_logging();
 	startup.init_logging();
 	startup.init_gc();
+	startup.init_errors();
 	startup.setup_plugindir();
+	startup.setup_plugin_install_path();
 	startup.setup_datadir();
 	startup.chdir();
 	startup.read_version();
 	startup.switch_user();
 	startup.check_dependencies();
-	startup.log_dependency_warnings();
+	startup.log_startup_warnings();
 	startup.check_unwriteable();
 	startup.load_libraries();
 	startup.init_http_client();
@@ -611,24 +678,29 @@
 function startup.prosody()
 	-- These actions are in a strict order, as many depend on
 	-- previous steps to have already been performed
+	prosody.process_type = "prosody";
 	startup.parse_args();
 	startup.init_global_state();
 	startup.read_config();
 	startup.init_logging();
 	startup.init_gc();
+	startup.init_errors();
 	startup.sanity_check();
 	startup.sandbox_require();
 	startup.set_function_metatable();
 	startup.check_dependencies();
 	startup.load_libraries();
 	startup.setup_plugindir();
+	startup.setup_plugin_install_path();
 	startup.setup_datadir();
 	startup.chdir();
 	startup.add_global_prosody_functions();
 	startup.read_version();
 	startup.log_greeting();
-	startup.log_dependency_warnings();
+	startup.log_startup_warnings();
 	startup.load_secondary_libraries();
+	startup.init_promise();
+	startup.init_async();
 	startup.init_http_client();
 	startup.init_data_store();
 	startup.init_global_protection();
--- a/util/statistics.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/statistics.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,160 +1,191 @@
-local t_sort = table.sort
-local m_floor = math.floor;
 local time = require "util.time".now;
+local new_metric_registry = require "util.openmetrics".new_metric_registry;
+local render_histogram_le = require "util.openmetrics".render_histogram_le;
 
-local function nop_function() end
+-- BEGIN of Metric implementations
+
+-- Gauges
+local gauge_metric_mt = {}
+gauge_metric_mt.__index = gauge_metric_mt
+
+local function new_gauge_metric()
+	local metric = { value = 0 }
+	setmetatable(metric, gauge_metric_mt)
+	return metric
+end
+
+function gauge_metric_mt:set(value)
+	self.value = value
+end
+
+function gauge_metric_mt:add(delta)
+	self.value = self.value + delta
+end
+
+function gauge_metric_mt:reset()
+	self.value = 0
+end
 
-local function percentile(arr, length, pc)
-	local n = pc/100 * (length + 1);
-	local k, d = m_floor(n), n%1;
-	if k == 0 then
-		return arr[1] or 0;
-	elseif k >= length then
-		return arr[length];
-	end
-	return arr[k] + d*(arr[k+1] - arr[k]);
+function gauge_metric_mt:iter_samples()
+	local done = false
+	return function(_s)
+		if done then
+			return nil, true
+		end
+		done = true
+		return "", nil, _s.value
+	end, self
+end
+
+-- Counters
+local counter_metric_mt = {}
+counter_metric_mt.__index = counter_metric_mt
+
+local function new_counter_metric()
+	local metric = {
+		_created = time(),
+		value = 0,
+	}
+	setmetatable(metric, counter_metric_mt)
+	return metric
+end
+
+function counter_metric_mt:set(value)
+	self.value = value
+end
+
+function counter_metric_mt:add(value)
+	self.value = (self.value or 0) + value
+end
+
+function counter_metric_mt:iter_samples()
+	local step = 0
+	return function(_s)
+		step = step + 1
+		if step == 1 then
+			return "_created", nil, _s._created
+		elseif step == 2 then
+			return "_total", nil, _s.value
+		else
+			return nil, nil, true
+		end
+	end, self
+end
+
+function counter_metric_mt:reset()
+	self.value = 0
 end
 
-local function new_registry(config)
-	config = config or {};
-	local duration_sample_interval = config.duration_sample_interval or 5;
-	local duration_max_samples = config.duration_max_stored_samples or 5000;
+-- Histograms
+local histogram_metric_mt = {}
+histogram_metric_mt.__index = histogram_metric_mt
 
-	local function get_distribution_stats(events, n_actual_events, since, new_time, units)
-		local n_stored_events = #events;
-		t_sort(events);
-		local sum = 0;
-		for i = 1, n_stored_events do
-			sum = sum + events[i];
-		end
+local function new_histogram_metric(buckets)
+	local metric = {
+		_created = time(),
+		_sum = 0,
+		_count = 0,
+	}
+	-- the order of buckets matters unfortunately, so we cannot directly use
+	-- the thresholds as table keys
+	for i, threshold in ipairs(buckets) do
+		metric[i] = {
+			threshold = threshold,
+			threshold_s = render_histogram_le(threshold),
+			count = 0
+		}
+	end
+	setmetatable(metric, histogram_metric_mt)
+	return metric
+end
 
-		return {
-			samples = events;
-			sample_count = n_stored_events;
-			count = n_actual_events,
-			rate = n_actual_events/(new_time-since);
-			average = n_stored_events > 0 and sum/n_stored_events or 0,
-			min = events[1] or 0,
-			max = events[n_stored_events] or 0,
-			units = units,
-		};
+function histogram_metric_mt:sample(value)
+	-- According to the I-D, values must be part of all buckets
+	for i, bucket in pairs(self) do
+		if "number" == type(i) and value <= bucket.threshold then
+			bucket.count = bucket.count + 1
+		end
 	end
+	self._sum = self._sum + value
+	self._count = self._count + 1
+end
 
+function histogram_metric_mt:iter_samples()
+	local key = nil
+	return function (_s)
+		local data
+		key, data = next(_s, key)
+		if key == "_created" or key == "_sum" or key == "_count" then
+			return key, nil, data
+		elseif key ~= nil then
+			return "_bucket", {["le"] = data.threshold_s}, data.count
+		else
+			return nil, nil, nil
+		end
+	end, self
+end
 
-	local registry = {};
-	local methods;
-	methods = {
-		amount = function (name, initial)
-			local v = initial or 0;
-			registry[name..":amount"] = function () return "amount", v; end
-			return function (new_v) v = new_v; end
-		end;
-		counter = function (name, initial)
-			local v = initial or 0;
-			registry[name..":amount"] = function () return "amount", v; end
-			return function (delta)
-				v = v + delta;
-			end;
-		end;
-		rate = function (name)
-			local since, n = time(), 0;
-			registry[name..":rate"] = function ()
-				local t = time();
-				local stats = {
-					rate = n/(t-since);
-					count = n;
-				};
-				since, n = t, 0;
-				return "rate", stats.rate, stats;
-			end;
-			return function ()
-				n = n + 1;
-			end;
-		end;
-		distribution = function (name, unit, type)
-			type = type or "distribution";
-			local events, last_event = {}, 0;
-			local n_actual_events = 0;
-			local since = time();
+function histogram_metric_mt:reset()
+	self._created = time()
+	self._count = 0
+	self._sum = 0
+	for i, bucket in pairs(self) do
+		if "number" == type(i) then
+			bucket.count = 0
+		end
+	end
+end
 
-			registry[name..":"..type] = function ()
-				local new_time = time();
-				local stats = get_distribution_stats(events, n_actual_events, since, new_time, unit);
-				events, last_event = {}, 0;
-				n_actual_events = 0;
-				since = new_time;
-				return type, stats.average, stats;
-			end;
+-- Summary
+local summary_metric_mt = {}
+summary_metric_mt.__index = summary_metric_mt
+
+local function new_summary_metric()
+	-- quantiles are not supported yet
+	local metric = {
+		_created = time(),
+		_sum = 0,
+		_count = 0,
+	}
+	setmetatable(metric, summary_metric_mt)
+	return metric
+end
 
-			return function (value)
-				n_actual_events = n_actual_events + 1;
-				if n_actual_events%duration_sample_interval == 1 then
-					last_event = (last_event%duration_max_samples) + 1;
-					events[last_event] = value;
-				end
-			end;
-		end;
-		sizes = function (name)
-			return methods.distribution(name, "bytes", "size");
-		end;
-		times = function (name)
-			local events, last_event = {}, 0;
-			local n_actual_events = 0;
-			local since = time();
+function summary_metric_mt:sample(value)
+	self._sum = self._sum + value
+	self._count = self._count + 1
+end
+
+function summary_metric_mt:iter_samples()
+	local key = nil
+	return function (_s)
+		local data
+		key, data = next(_s, key)
+		return key, nil, data
+	end, self
+end
 
-			registry[name..":duration"] = function ()
-				local new_time = time();
-				local stats = get_distribution_stats(events, n_actual_events, since, new_time, "seconds");
-				events, last_event = {}, 0;
-				n_actual_events = 0;
-				since = new_time;
-				return "duration", stats.average, stats;
-			end;
-
-			return function ()
-				n_actual_events = n_actual_events + 1;
-				if n_actual_events%duration_sample_interval ~= 1 then
-					return nop_function;
-				end
+function summary_metric_mt:reset()
+	self._created = time()
+	self._count = 0
+	self._sum = 0
+end
 
-				local start_time = time();
-				return function ()
-					local end_time = time();
-					local duration = end_time - start_time;
-					last_event = (last_event%duration_max_samples) + 1;
-					events[last_event] = duration;
-				end
-			end;
-		end;
+local pull_backend = {
+	gauge = new_gauge_metric,
+	counter = new_counter_metric,
+	histogram = new_histogram_metric,
+	summary = new_summary_metric,
+}
 
-		get_stats = function ()
-			return registry;
-		end;
-	};
-	return methods;
+-- END of Metric implementations
+
+local function new()
+	return {
+		metric_registry = new_metric_registry(pull_backend),
+	}
 end
 
 return {
-	new = new_registry;
-	get_histogram = function (duration, n_buckets)
-		n_buckets = n_buckets or 100;
-		local events, n_events = duration.samples, duration.sample_count;
-		if not (events and n_events) then
-			return nil, "not a valid distribution stat";
-		end
-		local histogram = {};
-
-		for i = 1, 100, 100/n_buckets do
-			histogram[i] = percentile(events, n_events, i);
-		end
-		return histogram;
-	end;
-
-	get_percentile = function (duration, pc)
-		local events, n_events = duration.samples, duration.sample_count;
-		if not (events and n_events) then
-			return nil, "not a valid distribution stat";
-		end
-		return percentile(events, n_events, pc);
-	end;
+	new = new;
 }
--- a/util/statsd.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/statsd.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -1,6 +1,231 @@
 local socket = require "socket";
+local time = require "util.time".now;
+local array = require "util.array";
+local t_concat = table.concat;
+
+local new_metric_registry = require "util.openmetrics".new_metric_registry;
+local render_histogram_le = require "util.openmetrics".render_histogram_le;
+
+-- BEGIN of Metric implementations
+
+-- Gauges
+local gauge_metric_mt = {}
+gauge_metric_mt.__index = gauge_metric_mt
+
+local function new_gauge_metric(full_name, impl)
+	local metric = {
+		_full_name = full_name;
+		_impl = impl;
+		value = 0;
+	}
+	setmetatable(metric, gauge_metric_mt)
+	return metric
+end
+
+function gauge_metric_mt:set(value)
+	self.value = value
+	self._impl:push_gauge(self._full_name, value)
+end
+
+function gauge_metric_mt:add(delta)
+	self.value = self.value + delta
+	self._impl:push_gauge(self._full_name, self.value)
+end
+
+function gauge_metric_mt:reset()
+	self.value = 0
+	self._impl:push_gauge(self._full_name, 0)
+end
+
+function gauge_metric_mt.iter_samples()
+	-- statsd backend does not support iteration.
+	return function()
+		return nil
+	end
+end
+
+-- Counters
+local counter_metric_mt = {}
+counter_metric_mt.__index = counter_metric_mt
+
+local function new_counter_metric(full_name, impl)
+	local metric = {
+		_full_name = full_name,
+		_impl = impl,
+		value = 0,
+	}
+	setmetatable(metric, counter_metric_mt)
+	return metric
+end
+
+function counter_metric_mt:set(value)
+	local delta = value - self.value
+	self.value = value
+	self._impl:push_counter_delta(self._full_name, delta)
+end
+
+function counter_metric_mt:add(value)
+	self.value = (self.value or 0) + value
+	self._impl:push_counter_delta(self._full_name, value)
+end
+
+function counter_metric_mt.iter_samples()
+	-- statsd backend does not support iteration.
+	return function()
+		return nil
+	end
+end
+
+function counter_metric_mt:reset()
+	self.value = 0
+end
+
+-- Histograms
+local histogram_metric_mt = {}
+histogram_metric_mt.__index = histogram_metric_mt
+
+local function new_histogram_metric(buckets, full_name, impl)
+	-- NOTE: even though the more or less proprietary dogstatsd has Its own
+	-- histogram implementation, we push the individual buckets in this statsd
+	-- backend for both consistency and compatibility across statsd
+	-- implementations.
+	local metric = {
+		_sum_name = full_name..".sum",
+		_count_name = full_name..".count",
+		_impl = impl,
+		_created = time(),
+		_sum = 0,
+		_count = 0,
+	}
+	-- the order of buckets matters unfortunately, so we cannot directly use
+	-- the thresholds as table keys
+	for i, threshold in ipairs(buckets) do
+		local threshold_s = render_histogram_le(threshold)
+		metric[i] = {
+			threshold = threshold,
+			threshold_s = threshold_s,
+			count = 0,
+			_full_name = full_name..".bucket."..(threshold_s:gsub("%.", "_")),
+		}
+	end
+	setmetatable(metric, histogram_metric_mt)
+	return metric
+end
 
-local time = require "util.time".now
+function histogram_metric_mt:sample(value)
+	-- According to the I-D, values must be part of all buckets
+	for i, bucket in pairs(self) do
+		if "number" == type(i) and value <= bucket.threshold then
+			bucket.count = bucket.count + 1
+			self._impl:push_counter_delta(bucket._full_name, 1)
+		end
+	end
+	self._sum = self._sum + value
+	self._count = self._count + 1
+	self._impl:push_gauge(self._sum_name, self._sum)
+	self._impl:push_counter_delta(self._count_name, 1)
+end
+
+function histogram_metric_mt.iter_samples()
+	-- statsd backend does not support iteration.
+	return function()
+		return nil
+	end
+end
+
+function histogram_metric_mt:reset()
+	self._created = time()
+	self._count = 0
+	self._sum = 0
+	for i, bucket in pairs(self) do
+		if "number" == type(i) then
+			bucket.count = 0
+		end
+	end
+	self._impl:push_gauge(self._sum_name, self._sum)
+end
+
+-- Summaries
+local summary_metric_mt = {}
+summary_metric_mt.__index = summary_metric_mt
+
+local function new_summary_metric(full_name, impl)
+	local metric = {
+		_sum_name = full_name..".sum",
+		_count_name = full_name..".count",
+		_impl = impl,
+	}
+	setmetatable(metric, summary_metric_mt)
+	return metric
+end
+
+function summary_metric_mt:sample(value)
+	self._impl:push_counter_delta(self._sum_name, value)
+	self._impl:push_counter_delta(self._count_name, 1)
+end
+
+function summary_metric_mt.iter_samples()
+	-- statsd backend does not support iteration.
+	return function()
+		return nil
+	end
+end
+
+function summary_metric_mt.reset()
+end
+
+-- BEGIN of statsd client implementation
+
+local statsd_mt = {}
+statsd_mt.__index = statsd_mt
+
+function statsd_mt:cork()
+	self.corked = true
+	self.cork_buffer = self.cork_buffer or {}
+end
+
+function statsd_mt:uncork()
+	self.corked = false
+	self:_flush_cork_buffer()
+end
+
+function statsd_mt:_flush_cork_buffer()
+	local buffer = self.cork_buffer
+	for metric_name, value in pairs(buffer) do
+		self:_send_gauge(metric_name, value)
+		buffer[metric_name] = nil
+	end
+end
+
+function statsd_mt:push_gauge(metric_name, value)
+	if self.corked then
+		self.cork_buffer[metric_name] = value
+	else
+		self:_send_gauge(metric_name, value)
+	end
+end
+
+function statsd_mt:_send_gauge(metric_name, value)
+	self:_send(self.prefix..metric_name..":"..tostring(value).."|g")
+end
+
+function statsd_mt:push_counter_delta(metric_name, delta)
+	self:_send(self.prefix..metric_name..":"..tostring(delta).."|c")
+end
+
+function statsd_mt:_send(s)
+	return self.sock:send(s)
+end
+
+-- END of statsd client implementation
+
+local function build_metric_name(family_name, labels)
+	local parts = array { family_name }
+	if labels then
+		parts:append(labels)
+	end
+	return t_concat(parts, "/"):gsub("%.", "_"):gsub("/", ".")
+end
 
 local function new(config)
 	if not config or not config.statsd_server then
@@ -12,71 +237,31 @@
 
 	local prefix = (config.prefix or "prosody")..".";
 
-	local function send_metric(s)
-		return sock:send(prefix..s);
-	end
-
-	local function send_gauge(name, amount, relative)
-		local s_amount = tostring(amount);
-		if relative and amount > 0 then
-			s_amount = "+"..s_amount;
-		end
-		return send_metric(name..":"..s_amount.."|g");
-	end
-
-	local function send_counter(name, amount)
-		return send_metric(name..":"..tostring(amount).."|c");
-	end
-
-	local function send_duration(name, duration)
-		return send_metric(name..":"..tostring(duration).."|ms");
-	end
-
-	local function send_histogram_sample(name, sample)
-		return send_metric(name..":"..tostring(sample).."|h");
-	end
+	local impl = {
+		metric_registry = nil;
+		sock = sock;
+		prefix = prefix;
+	};
+	setmetatable(impl, statsd_mt)
 
-	local methods;
-	methods = {
-		amount = function (name, initial)
-			if initial then
-				send_gauge(name, initial);
-			end
-			return function (new_v) send_gauge(name, new_v); end
-		end;
-		counter = function (name, initial) --luacheck: ignore 212/initial
-			return function (delta)
-				send_gauge(name, delta, true);
-			end;
-		end;
-		rate = function (name)
-			return function ()
-				send_counter(name, 1);
-			end;
+	local backend = {
+		gauge = function(family_name, labels)
+			return new_gauge_metric(build_metric_name(family_name, labels), impl)
 		end;
-		distribution = function (name, unit, type) --luacheck: ignore 212/unit 212/type
-			return function (value)
-				send_histogram_sample(name, value);
-			end;
+		counter = function(family_name, labels)
+			return new_counter_metric(build_metric_name(family_name, labels), impl)
 		end;
-		sizes = function (name)
-			name = name.."_size";
-			return function (value)
-				send_histogram_sample(name, value);
-			end;
+		histogram = function(buckets, family_name, labels)
+			return new_histogram_metric(buckets, build_metric_name(family_name, labels), impl)
 		end;
-		times = function (name)
-			return function ()
-				local start_time = time();
-				return function ()
-					local end_time = time();
-					local duration = end_time - start_time;
-					send_duration(name, duration*1000);
-				end
-			end;
+		summary = function(family_name, labels, extra)
+			return new_summary_metric(build_metric_name(family_name, labels), impl, extra)
 		end;
 	};
-	return methods;
+
+	impl.metric_registry = new_metric_registry(backend);
+
+	return impl;
 end
 
 return {
--- a/util/termcolours.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/termcolours.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -83,7 +83,7 @@
 setmetatable(stylemap, { __index = function(_, style)
 	if type(style) == "string" and style:find("%x%x%x%x%x%x") == 1 then
 		local g = style:sub(7) == " background" and "48;5;" or "38;5;";
-		return g .. color(hex2rgb(style));
+		return format("%s%d", g, color(hex2rgb(style)));
 	end
 end } );
 
--- a/util/timer.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/timer.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -17,6 +17,11 @@
 local math_max = math.max;
 local pairs = pairs;
 
+if server.timer then
+	-- The selected net.server implements this API, so defer to that
+	return server.timer;
+end
+
 local _ENV = nil;
 -- luacheck: std none
 
--- a/util/uuid.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/uuid.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -8,7 +8,7 @@
 
 local random = require "util.random";
 local random_bytes = random.bytes;
-local hex = require "util.hex".to;
+local hex = require "util.hex".encode;
 local m_ceil = math.ceil;
 
 local function get_nibbles(n)
--- a/util/vcard.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/vcard.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -29,7 +29,7 @@
 		["\\n"] = "\n",
 		["\\r"] = "\r",
 		["\\t"] = "\t",
-		["\\:"] = ":", -- FIXME Shouldn't need to espace : in values, just params
+		["\\:"] = ":", -- FIXME Shouldn't need to escape : in values, just params
 		["\\;"] = ";",
 		["\\,"] = ",",
 		[":"] = "\29",
--- a/util/x509.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/x509.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -20,9 +20,12 @@
 
 local nameprep = require "util.encodings".stringprep.nameprep;
 local idna_to_ascii = require "util.encodings".idna.to_ascii;
+local idna_to_unicode = require "util.encodings".idna.to_unicode;
 local base64 = require "util.encodings".base64;
 local log = require "util.logger".init("x509");
+local mt = require "util.multitable";
 local s_format = string.format;
+local ipairs = ipairs;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -216,6 +219,63 @@
 	return false
 end
 
+-- TODO Support other SANs
+local function get_identities(cert) --> map of names to sets of services
+	if cert.setencode then
+		cert:setencode("utf8");
+	end
+
+	local names = mt.new();
+
+	local ext = cert:extensions();
+	local sans = ext[oid_subjectaltname];
+	if sans then
+		if sans["dNSName"] then -- Valid for any service
+			for _, name in ipairs(sans["dNSName"]) do
+				local is_wildcard = name:sub(1, 2) == "*.";
+				if is_wildcard then name = name:sub(3); end
+				name = idna_to_unicode(nameprep(name));
+				if name then
+					if is_wildcard then name = "*." .. name; end
+					names:set(name, "*", true);
+				end
+			end
+		end
+		if sans[oid_xmppaddr] then
+			for _, name in ipairs(sans[oid_xmppaddr]) do
+				name = nameprep(name);
+				if name then
+					names:set(name, "xmpp-client", true);
+					names:set(name, "xmpp-server", true);
+				end
+			end
+		end
+		if sans[oid_dnssrv] then
+			for _, srvname in ipairs(sans[oid_dnssrv]) do
+				local srv, name = srvname:match("^_([^.]+)%.(.*)");
+				if srv then
+					name = nameprep(name);
+					if name then
+						names:set(name, srv, true);
+					end
+				end
+			end
+		end
+	end
+
+	local subject = cert:subject();
+	for i = 1, #subject do
+		local dn = subject[i];
+		if dn.oid == oid_commonname then
+			local name = nameprep(dn.value);
+			if name and idna_to_ascii(name) then
+				names:set(name, "*", true);
+			end
+		end
+	end
+	return names.data;
+end
+
 local pat = "%-%-%-%-%-BEGIN ([A-Z ]+)%-%-%-%-%-\r?\n"..
 "([0-9A-Za-z+/=\r\n]*)\r?\n%-%-%-%-%-END %1%-%-%-%-%-";
 
@@ -237,6 +297,7 @@
 
 return {
 	verify_identity = verify_identity;
+	get_identities = get_identities;
 	pem2der = pem2der;
 	der2pem = der2pem;
 };
--- a/util/xml.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/xml.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -72,11 +72,14 @@
 			end
 		end
 		handler.StartDoctypeDecl = restricted_handler;
-		handler.ProcessingInstruction = restricted_handler;
 		if not options or not options.allow_comments then
 			-- NOTE: comments are generally harmless and can be useful when parsing configuration files or other data, even user-provided data
 			handler.Comment = restricted_handler;
 		end
+		if not options or not options.allow_processing_instructions then
+			-- Processing instructions should generally be safe to just ignore
+			handler.ProcessingInstruction = restricted_handler;
+		end
 		local parser = lxp.new(handler, ns_separator);
 		local ok, err, line, col = parser:parse(xml);
 		if ok then ok, err, line, col = parser:parse(); end
@@ -84,7 +87,7 @@
 		if ok then
 			return stanza.tags[1];
 		else
-			return ok, err.." (line "..line..", col "..col..")";
+			return ok, ("%s (line %d, col %d))"):format(err, line, col);
 		end
 	end;
 end)();
--- a/util/xmppstream.lua	Mon Dec 12 07:03:31 2022 +0100
+++ b/util/xmppstream.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -64,6 +64,8 @@
 
 	local stream_default_ns = stream_callbacks.default_ns;
 
+	local stream_lang = "en";
+
 	local stack = {};
 	local chardata, stanza = {};
 	local stanza_size = 0;
@@ -101,6 +103,7 @@
 			if session.notopen then
 				if tagname == stream_tag then
 					non_streamns_depth = 0;
+					stream_lang = attr["xml:lang"] or stream_lang;
 					if cb_streamopened then
 						if lxp_supports_bytecount then
 							cb_handleprogress(stanza_size);
@@ -178,6 +181,9 @@
 					cb_handleprogress(stanza_size);
 				end
 				stanza_size = 0;
+				if stanza.attr["xml:lang"] == nil then
+					stanza.attr["xml:lang"] = stream_lang;
+				end
 				if tagname ~= stream_error_tag then
 					cb_handlestanza(session, stanza);
 				else
@@ -259,14 +265,13 @@
 			["xml:lang"] = "en",
 			xmlns = stream_callbacks.default_ns,
 			version = session.version and (session.version > 0 and "1.0" or nil),
-			id = session.streamid,
+			id = session.streamid or "",
 			from = from or session.host, to = to,
 		};
 		if session.stream_attrs then
 			session:stream_attrs(from, to, attr)
 		end
-		send("<?xml version='1.0'?>");
-		send(st.stanza("stream:stream", attr):top_tag());
+		send("<?xml version='1.0'?>"..st.stanza("stream:stream", attr):top_tag());
 		return true;
 	end
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/xtemplate.lua	Mon Dec 12 07:07:13 2022 +0100
@@ -0,0 +1,86 @@
+local s_gsub = string.gsub;
+local s_match = string.match;
+local s_sub = string.sub;
+local t_concat = table.concat;
+
+local st = require("util.stanza");
+
+local function render(template, root, escape, filters)
+	escape = escape or st.xml_escape;
+
+	return (s_gsub(template, "%b{}", function(block)
+		local inner = s_sub(block, 2, -2);
+		local path, pipe, pos = s_match(inner, "^([^|]+)(|?)()");
+		if not (type(path) == "string") then return end
+		local value
+		if path == "." then
+			value = root;
+		elseif path == "#" then
+			value = root:get_text();
+		else
+			value = root:find(path);
+		end
+		local is_escaped = false;
+
+		while pipe == "|" do
+			local func, args, tmpl, p = s_match(inner, "^(%w+)(%b())(%b{})()", pos);
+			if not func then func, args, p = s_match(inner, "^(%w+)(%b())()", pos); end
+			if not func then func, tmpl, p = s_match(inner, "^(%w+)(%b{})()", pos); end
+			if not func then func, p = s_match(inner, "^(%w+)()", pos); end
+			if not func then break end
+			if tmpl then tmpl = s_sub(tmpl, 2, -2); end
+			if args then args = s_sub(args, 2, -2); end
+
+			if func == "each" and tmpl and st.is_stanza(value) then
+				if not args then value, args = root, path; end
+				local ns, name = s_match(args, "^(%b{})(.*)$");
+				if ns then
+					ns = s_sub(ns, 2, -2);
+				else
+					name, ns = args, nil;
+				end
+				if ns == "" then ns = nil; end
+				if name == "" then name = nil; end
+				local out, i = {}, 1;
+				for c in (value):childtags(name, ns) do out[i], i = render(tmpl, c, escape, filters), i + 1; end
+				value = t_concat(out);
+				is_escaped = true;
+			elseif func == "and" and tmpl then
+				local condition = value;
+				if args then condition = root:find(args); end
+				if condition then
+					value = render(tmpl, root, escape, filters);
+					is_escaped = true;
+				end
+			elseif func == "or" and tmpl then
+				local condition = value;
+				if args then condition = root:find(args); end
+				if not condition then
+					value = render(tmpl, root, escape, filters);
+					is_escaped = true;
+				end
+			elseif filters and filters[func] then
+				local f = filters[func];
+				if args == nil then
+					value, is_escaped = f(value, tmpl);
+				else
+					value, is_escaped = f(args, value, tmpl);
+				end
+			else
+				error("No such filter function: " .. func);
+			end
+			pipe, pos = s_match(inner, "^(|?)()", p);
+		end
+
+		if type(value) == "string" then
+			if not is_escaped then value = escape(value); end
+			return value
+		elseif st.is_stanza(value) then
+			value = value:get_text();
+			if value then return escape(value) end
+		end
+		return ""
+	end))
+end
+
+return { render = render }