# HG changeset patch # User Matthew Wild # Date 1773765424 0 # Node ID 9f43226c7de13623e01a81d9af1b71bbc457815f # Parent be2f75957552b450efb933033369afb6a5203b8a mod_s2s_v6mesh: DNS-less federation for IPv6 networks diff -r be2f75957552 -r 9f43226c7de1 mod_s2s_v6mesh/README.md --- /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 `@`, 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 diff -r be2f75957552 -r 9f43226c7de1 mod_s2s_v6mesh/base32.lib.lua --- /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; +}; diff -r be2f75957552 -r 9f43226c7de1 mod_s2s_v6mesh/mod_s2s_v6mesh.lua --- /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; +});