changeset 6472:9f43226c7de1

mod_s2s_v6mesh: DNS-less federation for IPv6 networks
author Matthew Wild <mwild1@gmail.com>
date Tue, 17 Mar 2026 16:37:04 +0000
parents be2f75957552
children 28964419b395
files mod_s2s_v6mesh/README.md mod_s2s_v6mesh/base32.lib.lua mod_s2s_v6mesh/mod_s2s_v6mesh.lua
diffstat 3 files changed, 453 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_s2s_v6mesh/README.md	Tue Mar 17 16:37:04 2026 +0000
@@ -0,0 +1,166 @@
+---
+labels:
+- Stage-Alpha
+summary: "Federate using stable IPv6 addresses encoded as hostnames, e.g. in mesh networks"
+rockspec:
+  build:
+    modules:
+      mod_s2s_v6mesh.base32: base32.lib.lua
+---
+
+## Overview
+
+There are various virtual network overlay projects which provide peers with
+stable IPv6 addresses, including [Yggdrasil](https://yggdrasil-network.github.io/),
+[CJDNS](https://github.com/cjdelisle/cjdns/), and others.
+
+One problem is that these systems are generally peer-to-peer, and there is
+typically no standard DNS root for the private networks. Public DNS can be
+used, but that adds a dependency on the public internet which can cause
+problems, e.g. if the public internet becomes inaccessible.
+
+The "obvious" solution is to skip domain names, and just federate between IP
+addresses directly. Unfortunately this brings a lot of problems, such as
+the inability for IP addresses to have subdomains, which are used extensively
+in XMPP to allow hosting multiple services on a single IP address.
+
+This module provides an alternative solution. For every possible IPv6 address,
+it can generate an encoded hostname, which supports subdomains, and can be
+resolved to an IPv6 address without any external DNS server infrastructure.
+
+The domain names end with `.v6.alt`. The `.alt` TLD was defined by [RFC 9476](https://www.rfc-editor.org/rfc/rfc9476.html)
+specifically for non-DNS use cases such as this. `.v6.alt` is our own scheme.
+
+## Usage
+
+For this module to be useful for federation:
+
+- Both servers must have this module loaded
+- Both servers must have stable IPv6 addresses (if the IP address changes, the
+  hostname changes, and your XMPP identity changes - a new hostname is like
+  starting fresh).
+
+Although this module was designed with virtual/mesh networks in mind, it is
+possible to use it between any two servers with IPv6 addresses (check the
+section on connection security below if you are using it over unencrypted
+networks such as the public internet).
+
+Discover your hostname with the prosodyctl command (our example will use the
+IP address `2a00:1098:3a0::1` but you should replace it with your own):
+
+```
+$ prosodyctl shell v6alt get_domain localhost 2a00:1098:3a0::1
+OK: Encoded hostname: fiabbgadu-e.v6.alt
+```
+
+In this command, "localhost" must be an existing domain in your Prosody config
+("localhost" is in the default config), however it's required just to make the
+command work and has no effect on the output.
+
+You can then use this domain name in your config file, for example:
+
+```lua
+VirtualHost "fiabbgadu-e.v6.alt" -- Main host, for user accounts
+
+Component "groups.fiabbgadu-e.v6.alt" "muc" -- MUC on a subdomain
+```
+
+### Connection security
+
+By default, Prosody requires TLS for server-to-server connections. However, it
+is not possible to obtain CA-issued certificates for `.v6.alt` domains. This
+module disables certificate validation (it treats all certificates as trusted)
+for all `v6.alt` domains where the IP address matches the domain name that the
+remote server is claiming to be (the actual certificate contents are
+irrelevant and never checked, so self-signed certificates are fine).
+
+Additionally, if the IP is within one of the CIDR ranges listed in the
+`s2s_v6mesh_secure_ranges` configuration option, then the module will tell
+Prosody to not even perform TLS for the connection. Prosody will treat the
+connections as totally secure, so be very sure you trust the underlying
+network's security layer before adding an IP range to this list. If you want
+similar behaviour for non-`v6.alt` domains, look at [mod_secure_interfaces](https://modules.prosody.im/mod_secure_interfaces)
+and configure it with your mesh interface's address.
+
+Because of the above mechanisms in place, it is strongly recommended to keep
+Prosody's default settings of `s2s_require_encryption = true` and
+`s2s_secure_auth = true` so that connections not covered by this module are
+always encrypted and authenticated properly.
+
+### Connecting from clients
+
+Note that although Prosody supports `.v6.alt` domains for federation when this
+module is loaded, this domain scheme is not (currently?) supported by clients,
+and you'll need to specify the connection IP manually in order to connect.
+
+Your JID should always be entered as `<username>@<domain.v6.alt>`, and you
+should specify the server's IPv6 address in your client's advanced connection
+configuration screen.
+
+### HTTP
+
+Services that use HTTP such as file sharing, web clients, etc. will need to
+use the IP address in the URL, as clients and browsers won't be able to
+resolve the `.v6.alt` domain names. You will need to [configure Prosody's HTTP
+server](https://prosody.im/doc/http) appropriately for your deployment, e.g.
+
+```lua
+http_external_url = "https://[2a00:1098:3a0::1]:5281"
+```
+
+## Configuration
+
+  | Name                     | Default| Description
+  |--------------------------|--------|-------------------------------------------------------------------------
+  | s2s_v6mesh_secure_ranges | `{}`   | A list of IP ranges to treat as secure (bypassing TLS)
+
+## Compatibility
+
+  |Prosody-Version|Status
+  |---------------|----------------------
+  |trunk          | Works
+  |13.0           | Works
+  |0.12           | Does not work
+
+
+## Developers
+
+This section is for developers who want to encode/decode v6.alt addresses
+in their own code.
+
+### Encoding scheme
+
+1. Take an IPv6 address as raw packed bytes
+2. Encode it using base32 (RFC 4648), remove `====` padding from the end.
+3. Lowercase the output
+4. Find the first longest sequence of 'a' characters that is:
+     1. Not at the start of the string (start scanning from second position)
+     2. Two or more characters in length
+     3. Not including the end character
+
+     If found, replace this sequence with a hyphen (`-`).
+
+5. Append `.v6.alt`
+
+### Decoding scheme
+
+1. Take a hostname ending with `.v6.alt`
+2. Remove `.v6.alt`
+3. Remove any '.' characters and all content before them (`foo.bar` -> `bar`)
+4. If the string contains a hyphen, replace that hyphen with sufficient 'a' characters to make the string 26 characters in length. Strings with multiple hyphen characters are invalid and must be rejected.
+5. Uppercase the string
+6. Decode using base32
+
+### Test vectors
+
+| IP                                        | Domain                             |
+|-------------------------------------------|------------------------------------|
+| `2001:db8::1`                             | `eaaq3o-e.v6.alt`                  |
+| `0200::1`                                 | `ai-e.v6.alt`                      |
+| `200:cab8:deb4:ce60:4362:4492:e539:4b5`   | `aiamvog6wthgaq3cisjokoiewu.v6.alt`|
+| `::1`                                     | `a-e.v6.alt`                       |
+| `::`                                      | `a-a.v6.alt`                       | 
+| `fe80::1`                                 | `72-e.v6.alt`                      |
+| `ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff` | `77777777777777777777777774.v6.alt`|
+| `1111:0000:0000:1111::1111`               | `ceiqaaaaaairc-rce.v6.alt`         |
+| `1111:0000:0000:0000:1111::1111`          | `ceiq-eiraaaaaaarce.v6.alt`        |
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_s2s_v6mesh/base32.lib.lua	Tue Mar 17 16:37:04 2026 +0000
@@ -0,0 +1,138 @@
+--[[
+ borrowed from basexx library for Lua, under MIT License (MIT)
+ Copyright (c) 2013 aiq
+The MIT License (MIT)
+
+Copyright (c) 2013 aiq
+
+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.
+----------------------------------------------------------------------------]]
+
+local function ignore_set( str, set )
+   if set then
+      str = str:gsub( "["..set.."]", "" )
+   end
+   return str
+end
+
+local function divide_string( str, max )
+   local result = {}
+
+   local start = 1
+   for i = 1, #str do
+      if i % max == 0 then
+         table.insert( result, str:sub( start, i ) )
+         start = i + 1
+      elseif i == #str then
+         table.insert( result, str:sub( start, i ) )
+      end
+   end
+
+   return result
+end
+
+function to_bit( str )
+   return ( str:gsub( '.', function ( c )
+               local byte = string.byte( c )
+               local bits = {}
+               for _ = 1,8 do
+                  table.insert( bits, byte % 2 )
+                  byte = math.floor( byte / 2 )
+               end
+               return table.concat( bits ):reverse()
+            end ) )
+end
+
+local function number_to_bit( num, length )
+   local bits = {}
+
+   while num > 0 do
+      local rest = math.floor( math.fmod( num, 2 ) )
+      table.insert( bits, rest )
+      num = ( num - rest ) / 2
+   end
+
+   while #bits < length do
+      table.insert( bits, "0" )
+   end
+
+   return string.reverse( table.concat( bits ) )
+end
+
+local function pure_from_bit( str )
+   return ( str:gsub( '........', function ( cc )
+               return string.char( tonumber( cc, 2 ) )
+            end ) )
+end
+
+local function unexpected_char_error( str, pos )
+   local c = string.sub( str, pos, pos )
+   return string.format( "unexpected character at position %d: '%s'", pos, c )
+end
+
+local function from_basexx( str, alphabet, bits )
+   local result = {}
+   for i = 1, #str do
+      local c = string.sub( str, i, i )
+      if c ~= '=' then
+         local index = string.find( alphabet, c, 1, true )
+         if not index then
+            return nil, unexpected_char_error( str, i )
+         end
+         table.insert( result, number_to_bit( index - 1, bits ) )
+      end
+   end
+
+   local value = table.concat( result )
+   local pad = #value % 8
+   return pure_from_bit( string.sub( value, 1, #value - pad ) )
+end
+
+local function to_basexx( str, alphabet, bits, pad )
+   local bitString = to_bit( str )
+
+   local chunks = divide_string( bitString, bits )
+   local result = {}
+   for _,value in ipairs( chunks ) do
+      if ( #value < bits ) then
+         value = value .. string.rep( '0', bits - #value )
+      end
+      local pos = tonumber( value, 2 ) + 1
+      table.insert( result, alphabet:sub( pos, pos ) )
+   end
+
+   table.insert( result, pad )
+   return table.concat( result )
+end
+
+local base32Alphabet = "abcdefghijklmnopqrstuvwxyz234567"
+local base32PadMap = { "", "======", "====", "===", "=" }
+
+local function from_base32( str, ignore )
+   str = ignore_set( str, ignore )
+   return from_basexx( string.lower( str ), base32Alphabet, 5 )
+end
+
+function to_base32( str )
+   return to_basexx( str, base32Alphabet, 5, base32PadMap[ #str % 5 + 1 ] )
+end
+
+return {
+	decode = from_base32;
+	encode = to_base32;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_s2s_v6mesh/mod_s2s_v6mesh.lua	Tue Mar 17 16:37:04 2026 +0000
@@ -0,0 +1,149 @@
+--% requires: s2sout-pre-connect-event
+
+local basic_resolver = require "net.resolvers.basic";
+local base32 = module:require("base32");
+local net = require "prosody.util.net";
+
+local ip = require "prosody.util.ip";
+local parse_cidr = require "prosody.util.ip".parse_cidr;
+
+local secure_ranges = module:get_option_set("s2s_v6mesh_secure_ranges", {});
+
+local function is_secure_range(remote_ip)
+	for range in secure_ranges do
+		if ip.match(remote_ip, parse_cidr(range)) then
+			return true;
+		end
+	end
+	return false;
+end
+
+function lookup(dom)
+	local lendiff = 26 - #dom;
+
+	if lendiff < 0 then
+		return nil; -- domain invalid; too long
+	elseif lendiff > 0 then
+		dom = dom:gsub("%-", ("a"):rep(lendiff+1));
+		if #dom ~= 26 then
+			return nil; -- domain invalid
+		end
+	end
+
+	local raw = base32.decode(dom);
+	if not raw then return nil; end
+
+	return (net.ntop(raw):gsub("=+$", ""));
+end
+
+module:hook("s2sout-pre-connect", function(event)
+	local host = event.session.to_host;
+	local dom = host:match("([%w-]+)%.v6%.alt$");
+	if not dom then return; end
+
+	local target_ip = lookup(dom);
+	if not target_ip then
+		return;
+	end
+
+	module:log("debug", "Resolved %s to [%s]:%d", target_ip, 5269);
+
+	event.resolver = basic_resolver.new(target_ip, 5269, "tcp", {});
+
+	if is_secure_range(ip.new_ip(target_ip, "IPv6")) then
+		module:log("debug", "Treating non-TLS connection to %s as secure because it's in a secure IP range", host);
+		event.session.secure = true;
+	end
+end);
+
+module:hook("s2s-stream-features", function (event)
+	local host = event.origin.from_host;
+
+	local dom = host:match("([%w-]+)%.v6%.alt$");
+	if not dom then
+		return;
+	end
+
+	local remote_ip = ip.new_ip(event.origin.ip);
+
+	if lookup(dom) ~= remote_ip.packed then
+		module:log("warn", "Rejecting incoming connection from %s: unexpected IP %s", host, remote_ip);
+		return;
+	end
+
+	if is_secure_range(remote_ip) then
+		module:log("debug", "Treating non-TLS connection from %s as secure because it's in a secure IP range", host);
+		event.origin.secure = true;
+	end
+end, 100);
+
+module:hook("s2s-check-certificate", function(event)
+	local session, host = event.session, event.host;
+
+	local dom = host:match("([%w-]+)%.v6%.alt$");
+	if not dom then
+		return;
+	end
+
+	local remote_ip = ip.new_ip(session.ip);
+
+	if lookup(dom) ~= remote_ip.packed then
+		module:log("warn", "Unable to authenticate incoming connection from %s: unexpected IP %s", host, remote_ip);
+		return;
+	end
+
+	module:log("debug", "Trusting certificate for %s because of IP match", host);
+	session.cert_chain_status = "valid";
+	session.cert_identity_status = "valid";
+	return true;
+end, 100);
+
+module:add_item("shell-command", {
+	section = "v6alt";
+	section_desc = module.name.." utility commands";
+	name = "get_domain";
+	desc = "Convert an IPv6 address to a .v6.alt domain";
+	args = {
+		{ name = "host", type = "string" };
+		{ name = "ip", type = "string" };
+	};
+	host_selector = "host";
+	handler = function(self, host, ip_str) --luacheck: ignore 212/self 212/host
+		local user_ip = ip.new_ip(ip_str, "IPv6");
+		if not user_ip then
+			return nil, "Invalid IP address: "..tostring(ip_str);
+		end
+
+		local encoded = base32.encode(user_ip.packed):gsub("=+$", "");
+		local longest_a_start, longest_a_len;
+		for pos, seq in encoded:gmatch("()(aa+).", 2) do
+			if not longest_a_len or #seq >= longest_a_len then
+				longest_a_start, longest_a_len = pos, #seq;
+			end
+		end
+		if longest_a_len then
+			encoded = encoded:sub(1, longest_a_start - 1) .. "-" .. encoded:sub(longest_a_start + longest_a_len, -1);
+		end
+		return true, ("Encoded hostname: %s.v6.alt"):format(encoded);
+	end;
+});
+
+module:add_item("shell-command", {
+	section = "v6alt";
+	section_desc = module.name.." utility commands";
+	name = "get_ip";
+	desc = "Convert a .v6.alt domain to an IP address";
+	args = {
+		{ name = "host", type = "string" };
+		{ name = "domain", type = "string" };
+	};
+	host_selector = "host";
+	handler = function(self, host, domain) --luacheck: ignore 212/self 212/host
+		local encoded = domain:match("([%w-]+)%.v6%.alt$");
+		if not encoded then
+			return nil, "Invalid domain: "..domain;
+		end
+		local user_ip = lookup(encoded);
+		return true, ("IP address: %s"):format(user_ip);
+	end;
+});