Repository contains all you need to launch your personal XMPP server https://another.im
  • Erlang 63.6%
  • Shell 19.9%
  • Python 12.4%
  • Makefile 2.1%
  • Dockerfile 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-27 12:19:28 +02:00
directory The people directory's image builds tackyd from libtacky v1.1.0-beta1, the release with urn:tacky:directory:0 and the directory's rate, local-only and registry options, in place of develop's d0dbc0b6; make directory-image from the forge and make test-login (29 of 29) checked. 2026-10-21 19:58:04 +03:00
ejabberd mod_privilege grants the Telegram gateway an iq privilege on XEP-0191 (urn:xmpp:blocking, both, the same telegram_gateway access rule as its roster, message and presence grants), so tackygram 0.3.8 keeps the user's server blocklist and their Telegram blocklist the same both ways; the comment above mod_privilege and the README's Telegram section and test list say so. With an iq grant configured ejabberd 26.09 sends every component a <privilege/>, all "none" for one it grants nothing, so tests/xmpp-client.py's component command now prints only permissions of a type other than none, and an iq namespace's as "privilege iq <ns>: <type>"; make test-login checks the gateway gets the iq grant and that push.<domain> still gets none. Checked: make test, make test-login (all but the people-directory checks, which fail the same on unchanged master with the 6-day-old directory image). 2026-10-27 12:19:28 +02:00
tests mod_privilege grants the Telegram gateway an iq privilege on XEP-0191 (urn:xmpp:blocking, both, the same telegram_gateway access rule as its roster, message and presence grants), so tackygram 0.3.8 keeps the user's server blocklist and their Telegram blocklist the same both ways; the comment above mod_privilege and the README's Telegram section and test list say so. With an iq grant configured ejabberd 26.09 sends every component a <privilege/>, all "none" for one it grants nothing, so tests/xmpp-client.py's component command now prints only permissions of a type other than none, and an iq namespace's as "privilege iq <ns>: <type>"; make test-login checks the gateway gets the iq grant and that push.<domain> still gets none. Checked: make test, make test-login (all but the people-directory checks, which fail the same on unchanged master with the 6-day-old directory image). 2026-10-27 12:19:28 +02:00
.gitignore One script writes a deployment, env.sh at the top, in place of the Makefile's env recipe, push/settings.sh and tackygram/settings.sh (the user: "there are tackygram/settings.sh and other settings.sh, maybe we can do cleaner and simpler?"): make env checks DOMAIN and IP and runs it with CERTS, TACKYGRAM, ENGINE and the images, and every other setting reaches it as make passes what is given on its command line, its defaults (POW_DIFFICULTY, REGISTRATION_TIMEOUT, UPLOAD_URL, ACME_URL) now in the script alone, whose header lists every file and setting. It first checks everything it is given (CERTS, FCM_KEY as a service account's key, APNS_KEY_ID, APNS_TEAM_ID and IOS_BUNDLE when apushd/apns.p8 is there, the calls' range), so a refused make env no longer leaves settings.env and compose.env half rewritten, and then writes settings.env, secrets.env once, compose.env (the range for the gateway's ports written there directly rather than grepped back from the gateway's env), apushd/ (fcm.json copied, vapid.pem made once, settings.json as before) and, with TACKYGRAM=1, tackygram/env and its key, made once and handed to 65534 at 0400 through a container of the server's image as before. The gateway's TG_API_ID, TG_API_HASH, TACKYGRAM_CALL_PORTS and TACKYGRAM_CALL_STUN follow one rule, the environment's when given (empty too), else the file's, else the default; so an empty TG_API_ID= now clears the id where it was ignored. The STUN default is stun:<domain>:3478, ejabberd's own listener, written out rather than found by parsing ejabberd.yml with awk. The README's layout and Configuration, the Makefile's usage (with module-deb, which it lacked), compose.tackygram.yaml, module-deb.sh's README, tests/push.sh's header and .gitignore's comment follow; push/ and tackygram/ are gone. Checked: make env for a scratch domain five times in a row as on master (with FCM_KEY, apns.p8 and the APNs settings; again without FCM_KEY; with TACKYGRAM=1 and API credentials; with an empty STUN and another range; with nothing given), the files the same as master's for the same inputs but for the header comment of the gateway's env and what the previous commit changed (ACME_URL, CERTS, no CERTBOT_EMAIL), the same modes and owners, the secrets, vapid.pem, fcm.json and the gateway's key kept throughout, and the bad range and a non-Firebase FCM_KEY refused with nothing written; env.sh also under dash in the server's image, and shellcheck finds nothing; make test-login 12 of 12, make test-push 25 of 25 with an apushd image from its main, make test-acme 6 of 6. 2026-10-21 06:38:53 +03:00
compose.acme.yaml Let's Encrypt by ejabberd itself, replacing certbot, as feat/acme (d09b3bc) had it, re-done on master (the user chose ejabberd's ACME over certbot and Caddy: "use ejabberd"): CERTS=files|acme, the certbot container, certbot/run.sh, compose.certbot.yaml, CERTBOT_EMAIL and the API's reload_config grant gone. ejabberd.yml's acme names ca_url ACME_URL (a new macro, Let's Encrypt's directory by default, written by every make env), cert_type ec and auto false, and a plain listener on 8080 serves /.well-known/acme-challenge from ejabberd_acme, published as the host's 80 by compose.acme.yaml, since ejabberd's uid may not bind 80 in a rootless container. Not auto, because auto asks in one order for every name ejabberd routes (pubsub.<domain>, upload.<domain>, the connected components), which have no address here, and the order fails whole; so make up, when compose.env says CERTS=acme, waits for ejabberdctl started and, when list_certificates is empty, runs request_certificate for <domain>,conference.<domain>, failing with ejabberd's error. The certificate and the account key live in the database volume, and ejabberd renews in place a week before the end with no restart. tests/acme.sh (make test-acme) replaces tests/certbot.sh and runs make up for real against smallstep's step-ca with pebble-challtestsrv as its DNS (Pebble refuses p1_acme, which sends no User-Agent, and p1_acme refuses Pebble's media types and data: terms; Let's Encrypt does neither). The conflict with master was only the Makefile's usage text, which keeps the gateway's call settings. CERTS=files is unchanged. Checked: make test-acme 6 of 6 (make up asks, ejabberd holds and serves the certificate for both names, a second make up asks for nothing, apushd connects, ejabberd renews by itself a minute later and serves the new one). 2026-10-21 06:34:42 +03:00
compose.directory.yaml The people directory as people.<domain>, opt-in at make env DIRECTORY=1: libtacky's tackyd directory (tacky/xmpp/directory), an XEP-0114 component that finds a user by name, phone number or personal code among those who listed themselves (urn:tacky:directory:0, or its ad-hoc commands list and unlist) and an imported registry, answering XEP-0055 searches with the address and name only. Re-done on the tidied master from feat/people-directory (a1b031b): no directory/settings.sh, env.sh writes state/<domain>/directory/env (0600, people.<domain>, the component secret and four settings) and adds compose.directory.yaml and DIRECTORY_IMAGE to compose.env; the settings are DIRECTORY_COUNTRY_CODE (1-3 digits, a leading + dropped, default none), DIRECTORY_LOCAL_ONLY (1: only this server's users search, libtacky's default), DIRECTORY_SELF_PHONE and DIRECTORY_SELF_CODE (1, libtacky's default; 0 takes the phone or the code from the registry import alone), each the environment's when given, else directory/env's, else the default, as the gateway's are (keep now names its file), all checked before anything is written; its header and the README list them. libtacky has no image, so directory/Dockerfile makes one here: it builds tackyd from libtacky's source (git fetch of LIBTACKY_REF from LIBTACKY_GIT, make tackyd MEDIA_BACKEND=none STRIP=1) and runs it on trixie-slim, the store the volume /var/lib/directory; LIBTACKY_REF is libtacky's develop d0dbc0b6 for now, not yet on the forge, and becomes v1.1.0-beta1 when that is tagged. The Makefile gains directory-image (LIBTACKY_REF= and LIBTACKY_GIT= passed as build arguments when given), directory-publish and directory-import DOMAIN=... CSV=... (a registry into the running service, all or nothing), and passes DIRECTORY and DIRECTORY_IMAGE to env.sh. compose.directory.yaml runs it in ejabberd's network namespace like apushd, on the loopback component port. ejabberd.yml needs no new option (the component listener takes any name, mod_disco lists a connected component among the domain's items), only its comments name the directory. The README has a People directory section (the settings, search and its limits, opt-in and the unchecked self-listed phone or code, the registry, the store unencrypted at rest, counts-only logging, the image). make test-login, when the directory's image is there, writes the env with make env DIRECTORY=1 and runs the directory beside the server on a volume of its own: tests/xmpp-client.py's people command checks disco, ann's listing, bob finding her by name, by phone without its country code and by code, a one-letter name refused not-acceptable and a duplicate code conflict, and its component command, given a service, searches as elsewhere.test, refused forbidden; then make env DIRECTORY_LOCAL_ONLY=0 DIRECTORY_SELF_CODE=0 and a restart on the same store, after which that search finds her, bob's own code is refused not-allowed while his name is listed, and once she unlists nobody finds her. Checked: the directory's image built from d0dbc0b6 (fetched from a local clone); make test-login 23 of 23; make test-push 25 of 25 and make test-acme 6 of 6 with this image; make env for a scratch domain twice without DIRECTORY, twice with it, with other settings and without again, the secrets, vapid.pem and the gateway's key kept throughout, the settings kept when not given, a bad country code or switch refused with nothing written, and compose config with the gateway and the directory; the image imports a registry from stdin and refuses a bad line; shellcheck finds nothing new. 2026-10-21 07:15:57 +03:00
compose.tackygram.yaml One script writes a deployment, env.sh at the top, in place of the Makefile's env recipe, push/settings.sh and tackygram/settings.sh (the user: "there are tackygram/settings.sh and other settings.sh, maybe we can do cleaner and simpler?"): make env checks DOMAIN and IP and runs it with CERTS, TACKYGRAM, ENGINE and the images, and every other setting reaches it as make passes what is given on its command line, its defaults (POW_DIFFICULTY, REGISTRATION_TIMEOUT, UPLOAD_URL, ACME_URL) now in the script alone, whose header lists every file and setting. It first checks everything it is given (CERTS, FCM_KEY as a service account's key, APNS_KEY_ID, APNS_TEAM_ID and IOS_BUNDLE when apushd/apns.p8 is there, the calls' range), so a refused make env no longer leaves settings.env and compose.env half rewritten, and then writes settings.env, secrets.env once, compose.env (the range for the gateway's ports written there directly rather than grepped back from the gateway's env), apushd/ (fcm.json copied, vapid.pem made once, settings.json as before) and, with TACKYGRAM=1, tackygram/env and its key, made once and handed to 65534 at 0400 through a container of the server's image as before. The gateway's TG_API_ID, TG_API_HASH, TACKYGRAM_CALL_PORTS and TACKYGRAM_CALL_STUN follow one rule, the environment's when given (empty too), else the file's, else the default; so an empty TG_API_ID= now clears the id where it was ignored. The STUN default is stun:<domain>:3478, ejabberd's own listener, written out rather than found by parsing ejabberd.yml with awk. The README's layout and Configuration, the Makefile's usage (with module-deb, which it lacked), compose.tackygram.yaml, module-deb.sh's README, tests/push.sh's header and .gitignore's comment follow; push/ and tackygram/ are gone. Checked: make env for a scratch domain five times in a row as on master (with FCM_KEY, apns.p8 and the APNs settings; again without FCM_KEY; with TACKYGRAM=1 and API credentials; with an empty STUN and another range; with nothing given), the files the same as master's for the same inputs but for the header comment of the gateway's env and what the previous commit changed (ACME_URL, CERTS, no CERTBOT_EMAIL), the same modes and owners, the secrets, vapid.pem, fcm.json and the gateway's key kept throughout, and the bad range and a non-Firebase FCM_KEY refused with nothing written; env.sh also under dash in the server's image, and shellcheck finds nothing; make test-login 12 of 12, make test-push 25 of 25 with an apushd image from its main, make test-acme 6 of 6. 2026-10-21 06:38:53 +03:00
compose.yaml A SOCKS5 bytestreams proxy (XEP-0065), ejabberd's mod_proxy65, as proxy.<domain> on port 7777, so libtacky 1.1.0-beta1's Jingle SOCKS5 transport (XEP-0260) can relay a file through the server when the two peers cannot connect to each other. ejabberd 26.09 binds the proxy's own listener at ip and names as its streamhost hostname when set, else ip as text; so ip is "::" (every address in the container, as the other listeners) and hostname is TURN_IP, the server's public address make env already writes from IP, no new macro, and env.sh is unchanged. access local (the local users only; a component of another domain is refused forbidden), shaper proxy65_shaper (a new shaper proxy_rate of 2 MiB/s per transfer, so a 100 MiB file, the upload limit, takes under a minute, the admin unshaped) and max_connections 10 per initiator. compose.yaml publishes 7777 tcp. The README has a file transfer row, IP as the proxy's streamhost too, 7777 among the ports to open in the firewall, and in DNS that proxy.<domain> needs no record since clients reach it by address; make dns writes that as a comment with the optional A/AAAA records. tests/xmpp-client.py's new proxy65 command reads the proxy's disco#info and streamhost, connects two users to it as SOCKS5 (at the test's host, not the address named), activates the stream and relays 64 KiB; its component command asks for a streamhost given streamhost:. make test-login now writes the env with IP=192.0.2.7 (TEST-NET, so it cannot be the container's own) and checks proxy.<domain> among the domain's items, its identity proxy/bytestreams, the streamhost 192.0.2.7 port 7777, ann's activation to bob with the bytes arriving the same, and elsewhere.test refused. Checked: make test-login 28 of 28 (with the directory's image), make test-push 25 of 25 with localhost/apushd:urn, make test-acme 6 of 6, the build stage without cache (eunit all 54 tests, mod_apush 99.7 %, the others 100 %), ejabberd's log naming the listener at [::]:7777 and no warning of the options; make env for a scratch domain twice, the same files byte for byte, and compose config publishing 7777. 2026-10-21 07:35:16 +03:00
env.sh The people directory as people.<domain>, opt-in at make env DIRECTORY=1: libtacky's tackyd directory (tacky/xmpp/directory), an XEP-0114 component that finds a user by name, phone number or personal code among those who listed themselves (urn:tacky:directory:0, or its ad-hoc commands list and unlist) and an imported registry, answering XEP-0055 searches with the address and name only. Re-done on the tidied master from feat/people-directory (a1b031b): no directory/settings.sh, env.sh writes state/<domain>/directory/env (0600, people.<domain>, the component secret and four settings) and adds compose.directory.yaml and DIRECTORY_IMAGE to compose.env; the settings are DIRECTORY_COUNTRY_CODE (1-3 digits, a leading + dropped, default none), DIRECTORY_LOCAL_ONLY (1: only this server's users search, libtacky's default), DIRECTORY_SELF_PHONE and DIRECTORY_SELF_CODE (1, libtacky's default; 0 takes the phone or the code from the registry import alone), each the environment's when given, else directory/env's, else the default, as the gateway's are (keep now names its file), all checked before anything is written; its header and the README list them. libtacky has no image, so directory/Dockerfile makes one here: it builds tackyd from libtacky's source (git fetch of LIBTACKY_REF from LIBTACKY_GIT, make tackyd MEDIA_BACKEND=none STRIP=1) and runs it on trixie-slim, the store the volume /var/lib/directory; LIBTACKY_REF is libtacky's develop d0dbc0b6 for now, not yet on the forge, and becomes v1.1.0-beta1 when that is tagged. The Makefile gains directory-image (LIBTACKY_REF= and LIBTACKY_GIT= passed as build arguments when given), directory-publish and directory-import DOMAIN=... CSV=... (a registry into the running service, all or nothing), and passes DIRECTORY and DIRECTORY_IMAGE to env.sh. compose.directory.yaml runs it in ejabberd's network namespace like apushd, on the loopback component port. ejabberd.yml needs no new option (the component listener takes any name, mod_disco lists a connected component among the domain's items), only its comments name the directory. The README has a People directory section (the settings, search and its limits, opt-in and the unchecked self-listed phone or code, the registry, the store unencrypted at rest, counts-only logging, the image). make test-login, when the directory's image is there, writes the env with make env DIRECTORY=1 and runs the directory beside the server on a volume of its own: tests/xmpp-client.py's people command checks disco, ann's listing, bob finding her by name, by phone without its country code and by code, a one-letter name refused not-acceptable and a duplicate code conflict, and its component command, given a service, searches as elsewhere.test, refused forbidden; then make env DIRECTORY_LOCAL_ONLY=0 DIRECTORY_SELF_CODE=0 and a restart on the same store, after which that search finds her, bob's own code is refused not-allowed while his name is listed, and once she unlists nobody finds her. Checked: the directory's image built from d0dbc0b6 (fetched from a local clone); make test-login 23 of 23; make test-push 25 of 25 and make test-acme 6 of 6 with this image; make env for a scratch domain twice without DIRECTORY, twice with it, with other settings and without again, the secrets, vapid.pem and the gateway's key kept throughout, the settings kept when not given, a bad country code or switch refused with nothing written, and compose config with the gateway and the directory; the image imports a registry from stdin and refuses a bad line; shellcheck finds nothing new. 2026-10-21 07:15:57 +03:00
Makefile Merge feat/client-access into master: mod_client_access, XEP-0494 Client Access Management (urn:xmpp:cam:0) with urn:tacky:client-access:0's sessions and push registrations, for app 0.7.8's signed-in devices list and "End session", the fourth module package ejabberd-mod-client-access and make test-client-access (the user, 2026-10-27: "yes" to merging it into master). The Makefile's targets and tests/xmpp-client.py's commands keep both sides (test-ping and idle beside test-client-access and cam); sasl2's Bind 2 extras take the client-access name bind_extra, which idle now passes. 2026-10-27 11:28:14 +02:00
README.md mod_privilege grants the Telegram gateway an iq privilege on XEP-0191 (urn:xmpp:blocking, both, the same telegram_gateway access rule as its roster, message and presence grants), so tackygram 0.3.8 keeps the user's server blocklist and their Telegram blocklist the same both ways; the comment above mod_privilege and the README's Telegram section and test list say so. With an iq grant configured ejabberd 26.09 sends every component a <privilege/>, all "none" for one it grants nothing, so tests/xmpp-client.py's component command now prints only permissions of a type other than none, and an iq namespace's as "privilege iq <ns>: <type>"; make test-login checks the gateway gets the iq grant and that push.<domain> still gets none. Checked: make test, make test-login (all but the people-directory checks, which fail the same on unchanged master with the 6-day-old directory image). 2026-10-27 12:19:28 +02:00

anotherim-server

The XMPP server for the AnotherIM clients: ejabberd 26.09 (ProcessOne's release) with two modules of ours and one configuration for every deployment, run by compose (Docker or Podman) with the push daemon apushd beside it and, if you like, the Telegram gateway tackygram and the people directory. The Makefile builds the image and writes a deployment's environment and DNS zone.

anotherim-server/
  Makefile                 image, publish, tests, env, dns, certs-selfsigned, up/down/logs/status
  compose.yaml             ejabberd and apushd
  compose.acme.yaml        port 80 for ejabberd's own Let's Encrypt, when CERTS=acme
  compose.tackygram.yaml   the Telegram gateway beside them, when TACKYGRAM=1
  compose.directory.yaml   the people directory beside them, when DIRECTORY=1
  directory/Dockerfile     the people directory's image: libtacky's tackyd, built from its source
  env.sh                   make env: every file of a deployment, from its settings
  ejabberd/
    Dockerfile             ejabberd 26.09-1 on Debian trixie; the modules compiled with the
                           release's erl, their tests run in the build
    ejabberd.yml           the configuration, carried by the image
    build-modules.sh       compiles the modules and runs their tests
    test_runner.erl        runs the tests under cover; starts the ejabberd they test on
    modules/
      mod_apush/           what a device is woken for, sealed to its key (a mod_push plugin)
      mod_s3_upload/       file upload into an S3 bucket
      mod_component_iq/    a component's iqs to pubsub and upload not dropped by mod_privilege
      mod_schedule/        a message held and sent later as the user (urn:tacky:schedule:0)
      mod_client_access/   XEP-0494: a user's sessions, FAST tokens and push registrations, listed and revoked
  tests/                   live tests of the image and their client
  state/<domain>/          a deployment's settings, secrets, zone and certificate (not in git)

What the server does

registration In band (XEP-0077) behind a proof of work: an XEP-0158 SHA-256 hashcash, captcha_pow with mod_register. Invitations (XEP-0401, XEP-0445) register without one. One registration per IP per REGISTRATION_TIMEOUT.
login SASL2 (XEP-0388) with Bind 2 and FAST tokens (XEP-0484); SCRAM-SHA-256 and -512, with channel binding, are the only password mechanisms, and passwords are stored only as those hashes. See "Login" below.
push XEP-0357 by mod_push, with mod_apush deciding each push to another.im's apps (other clients' registrations, such as Conversations' or Monal's, are mod_push's alone): an incoming message or call only, the summary sealed to the device's key and naming the contact or room as the user's roster and bookmarks do; muted chats, mentions-only rooms and a grace period while the user is active elsewhere are held. A room's message that mentions the user (the nick, a XEP-0372 reference, or a XEP-0513 mention by occupant id, JID or #channel) is flagged mention in the summary and is never held by the window; a message sent quietly (urn:tacky:silent:0) is flagged silent, pushed all the same. A burst in one chat is one push at once and, after a 20-second window, one with the latest count. The App Server is apushd. Details: ejabberd/modules/mod_apush/README.md.
scheduled messages mod_schedule (urn:tacky:schedule:0): a user's message held in Mnesia (replicated over a cluster, and sent once by whichever node claims it; SQL with db_type: sql) and sent at its time as the user's bare JID, into their archive and their devices' carbons; listed, changed, cancelled or sent now from any device, every change told to all of them. A year ahead, 100 per chat, 500 per account at most. The Telegram gateway's contacts are scheduled in Telegram itself (a submit for one is redirected there). Details: ejabberd/modules/mod_schedule/README.md.
sessions mod_client_access, XEP-0494 Client Access Management (urn:xmpp:cam:0, 0.1.0) for the user's own account: the live sessions (device and software as the SASL2 user agent named them, address, resource, since when), sessions waiting for XEP-0198 resumption, clients holding a FAST token and push registrations, each a client with first and last seen; revoking one deletes its tokens and push registrations and ends its sessions (a hibernated one not resumable), so only the password lets it back. A client not connected is remembered 30 days, its address not kept. Details: ejabberd/modules/mod_client_access/README.md.
uploads XEP-0363 on upload.<domain> by mod_s3_upload, into an S3 bucket run elsewhere. See "Uploads" below.
Telegram Optional: the gateway tackygram as the component telegram.<domain>, with the XEP-0356 privileges mod_privilege grants that name alone. See "Telegram" below.
sticker packs A catalogue of public XEP-0449 packs on pubsub.<domain>, published by the admin (libtacky's tackyd stickers publish) and by the Telegram gateway (sets its users import). See "Sticker packs" below.
people directory Optional: people.<domain>, a component that finds a person by name, phone number or personal code among the users who listed themselves, and an imported registry. See "People directory" below.
file transfer A SOCKS5 bytestreams proxy (XEP-0065, mod_proxy65) as proxy.<domain> on port 7777, for the local users: the relay a Jingle file transfer's SOCKS5 transport (XEP-0260) falls back on when the two peers cannot connect to each other. It names the server's public IP as its streamhost; each transfer is held to 2 MiB/s (the admin's not), a user to ten at once.
calls STUN and TURN on 3478 (udp and tcp, relay ports 49160-49199 udp); mod_stun_disco (XEP-0215) gives every client temporary TURN credentials.
the rest Message archive (MAM) in SQLite, MUC on conference.<domain>, PEP and bookmarks, carbons, avatars, blocking, stream management, client state, server pings (XEP-0199: a client silent for two minutes is pinged and, unanswered in 32 seconds, disconnected; one with stream management is asked for an acknowledgement instead, given 60 seconds, and its session then held for resumption, five minutes, or mod_push_keepalive's 24 hours with push enabled), vCard, the WebSocket and host-meta on 5443, the admin API on the loopback's 5280.

Everything but the archive is in mnesia. Past a few thousand users the archive moves to PostgreSQL: sql_type: pgsql and its connection options.

Running a server

make env  DOMAIN=chat.example.org IP=203.0.113.7 CERTS=acme
make dns  DOMAIN=chat.example.org IP=203.0.113.7 IP6=2001:db8::7
make up   DOMAIN=chat.example.org
make status DOMAIN=chat.example.org     # also: make logs, make down

make env writes everything a deployment is into state/<domain>/. make up runs compose on it:

docker compose --env-file state/chat.example.org/compose.env up -d    # or podman compose

That is all make up does, but for asking for the certificate once with CERTS=acme (below), so compose can be used the same way by hand. IP is the server's public IPv4 address: the TURN relay's, and the zone's.

Images

image from default
the server make image here dev.another.im/another-im/anotherim-server:latest
apushd its own repository (make image there) dev.another.im/another-im/apushd:latest
tackygram, with TACKYGRAM=1 its own repository (make image there) dev.another.im/another-im/tackygram:latest
the people directory, with DIRECTORY=1 make directory-image here dev.another.im/another-im/people-directory:latest

They are on the registry at dev.another.im: make publish pushes each from its own repository (the directory's is make directory-publish here), after you log in to dev.another.im. A server pulls what it does not have. Other names are IMAGE=, APUSHD_IMAGE=, TACKYGRAM_IMAGE= and DIRECTORY_IMAGE= at make env.

Certificate

Two ways, chosen at make env:

  • CERTS=files, the default. Put PEM files in state/<domain>/certs/: your own certificate (a wildcard one is fine) with its chain and key, in one file or several, readable by everyone (0644), since ejabberd runs as uid 9000. For a trial, make certs-selfsigned DOMAIN=... makes one.
  • CERTS=acme. ejabberd gets a Let's Encrypt certificate itself, for the domain and conference.<domain>, over HTTP-01 on port 80, which then has to reach this host for both names (make dns's records). make up asks for it once ejabberd has started, when ejabberd holds none, and stops with ejabberd's error if it fails; run it again once DNS and port 80 are right. The certificate and the ACME account key are kept in the database volume, beside mnesia's files, and ejabberd renews the certificate in place a week before it expires, with no restart and no other container. It asks for those two names only, not for every name it routes (acme: auto would add pubsub., upload. and the components, which have no address here). ACME_URL at make env names another ACME directory; Let's Encrypt's is the default.

Configuration

ejabberd/ejabberd.yml is the same everywhere. A deployment is ejabberd's EJABBERD_MACRO_<NAME> variables in two files the container gets:

  • settings.env, rewritten by every make env:

    variable default
    DOMAIN the XMPP domain required
    IP the server's public IPv4 address: the TURN relay's, and the streamhost the bytestreams proxy names required
    POW_DIFFICULTY hashcash hex digits; 6 is a few seconds for a client 6
    REGISTRATION_TIMEOUT seconds between registrations per IP, or infinity 600
    UPLOAD_URL the upload bucket, path style https://upload.<domain>/files/
    ACME_URL the ACME directory, with CERTS=acme Let's Encrypt's
  • secrets.env (0600), made once with the component and STUN secrets. The upload store's key is added to it by hand (see "Uploads").

  • compose.env, compose's own variables: the project's name, the compose files, the domain and the images.

Beside them are apushd/ (Push, below) and, with TACKYGRAM=1, tackygram/ (Telegram, below), with DIRECTORY=1 directory/ (People directory, below). All of it is written by env.sh, which make env runs and whose header lists every file and setting. It checks what it is given before it writes anything, so a refused make env changes nothing, and running it again keeps the secrets and keys.

In the configuration the domain is the macro XMPP_DOMAIN where a whole value is the domain, and the keyword @DOMAIN@ inside module options. A listener's hosts takes neither, so the component listener accepts its secret for any component name. It listens on 127.0.0.1 only.

DNS

make dns writes the records the domain needs:

  • A, and AAAA with IP6, for the domain and conference;
  • SRV for clients (_xmpp-client._tcp 5222, _xmpps-client._tcp 5223);
  • SRV for servers (_xmpp-server._tcp 5269, also for conference);
  • SRV for calls (_stun and _turn, udp and tcp, 3478).

The upload host points at the store. proxy.<domain>, the bytestreams proxy, needs no record: a client asks it over XMPP, and its answer gives the server's IP and port 7777, which the client connects to by address. make dns lists it as a comment, A/AAAA records to add only if you like the name to resolve.

Ports

Published on the host:

  • 5222 and 5223 for clients (STARTTLS and direct TLS);
  • 5269 for other servers;
  • 5443 for the WebSocket and the .well-known documents;
  • 3478 udp and tcp for STUN and TURN, with the relay range 49160-49199 udp;
  • 7777 tcp for the SOCKS5 bytestreams proxy (file transfers between peers that cannot connect to each other); open it in the firewall to wherever the users' clients are, on the address IP names;
  • 80 for the ACME challenges, with CERTS=acme (ejabberd's 8080 in the container: its uid may not bind 80 in a rootless one).

The component port 5347 and the admin API on 5280 stay inside the container. A reverse proxy on 443 is the deployment's choice.

Push

apushd runs in ejabberd's network namespace: it connects to the component port on 127.0.0.1, and nothing outside can reach it. Its settings are state/<domain>/apushd/settings.json, which make env writes with one push service per key it finds in that directory:

push service for what it needs
Web Push the web app, the installed PWA, Android over UnifiedPush nothing: make env makes the VAPID key (vapid.pem) once
APNs iOS, alerts and PushKit calls apns.p8, with APNS_KEY_ID, APNS_TEAM_ID and IOS_BUNDLE at make env
FCM Android over Firebase fcm.json, the Firebase service account's key: FCM_KEY= at make env

make env ... FCM_KEY=/path/to/key.json copies the key Firebase's console gives (Project settings, Service accounts, Generate new private key) to apushd/fcm.json, after checking that it is a service account's key (its type is service_account, with project_id, client_email and private_key); nothing of it is printed. It is given once: a later make env without FCM_KEY keeps the copy, and one with it replaces the copy. The module is anotherimAndroid. Removing fcm.json and running make env again turns FCM off.

The clients find the Web Push key and the FCM module's name through apushd's disco (urn:tacky:push:webpush:0 and urn:tacky:push:fcm:0), so nothing in the apps depends on them; an Android app on a server with no FCM module stays connected instead. apushd runs as nobody, so this directory is readable by every user of the host.

Telegram

make env ... TACKYGRAM=1 TG_API_ID=... TG_API_HASH=... adds the gateway (compose.tackygram.yaml); the API id and hash are an application's, from https://my.telegram.org. Like CERTS=acme, TACKYGRAM=1 is given at every make env, since compose.env is rewritten; the API id and hash are kept from the first time. The gateway runs in ejabberd's network namespace as the component telegram.<domain>, and users of the domain link their Telegram account through its ad-hoc commands (by QR code or phone number). ejabberd gives that name, and no other, the XEP-0356 privileges it needs: the linked contacts go into the user's roster with their presence, and what the user writes from Telegram's own apps comes back as carbons, and the user's XEP-0191 blocklist on the server and their Telegram blocklist are kept the same both ways (an iq privilege on urn:xmpp:blocking). The same name, and no other component, may ask upload.<domain> for a slot: the stickers users get from Telegram go there (see "Uploads"). ejabberd's mod_privilege drops every iq a component sends to a bare JID that is not the domain itself, such as upload.<domain> and pubsub.<domain> (processone/ejabberd#4472, open); mod_component_iq, ours, lets those that wrap no privileged iq go as they are. It may also create and publish on pubsub.<domain>, where its users import Telegram sticker sets into the sticker catalogue (see "Sticker packs"). Users of other servers cannot use it, so the name needs no DNS record.

Voice calls go through the gateway too: a Telegram contact rings the user's devices over Jingle, and the user calls Telegram users from their contact. The gateway's engine is one end of the media towards the devices (ICE over UDP, DTLS-SRTP), so the host opens what it listens on:

what default set at make env with
UDP ports, published one to one on ejabberd's service (the gateway shares its network namespace) 50000-50099 TACKYGRAM_CALL_PORTS=min-max
the public address put in the gateway's candidates IP, as for TURN TACKYGRAM_CALL_IP= (comma-separated), at every make env
the STUN server it asks for its reflexive address stun:<domain>:3478, ejabberd's own listener TACKYGRAM_CALL_STUN= (a URL, or empty for none)

Open that UDP range in the firewall to wherever the users' clients are, beside 3478 and the TURN relay's 49160-49199, which serve the clients that cannot reach it directly. A call takes a port or two, so the range bounds the calls at once. The Telegram side of a call goes out to Telegram over UDP and needs nothing opened. The range and the STUN server are kept from one make env to the next; compose.env gets the range for the ports.

state/<domain>/tackygram/ holds env (the gateway's domain, the component secret, the API credentials and the calls' settings, 0600) and key, the master key the users' Telegram sessions are sealed under, made by the first make env with TACKYGRAM=1 and handed to the container's user (0400). The sessions are in the compose volume tackygram, which opens nothing without the key: back the key up apart from the volume (with Podman it reads as podman unshare cat state/<domain>/tackygram/key). Without it, every user links again.

The image is tackygram's two executables on Debian's slim base: tackygram, the component with its own Tcl and libtacky inside, and paperplaned, the Telegram engine, which tackygram runs beside it; no Tcl is installed. Once ejabberd is up the gateway logs connected as telegram.<domain>, and ejabberd logs the permissions it grants it.

To try it with no Telegram account or API credentials, run the gateway on its engine's fake Telegram, with a compose file of your own beside the others:

# fake.yaml
services:
  tackygram:
    command: [-data, /var/lib/tackygram, -engine-args, -fake]
COMPOSE_FILE=compose.yaml:compose.tackygram.yaml:fake.yaml \
    docker compose --env-file state/<domain>/compose.env up -d

The fake account links by QR code or by phone +15550000000 with code 12345, and its contact @alice answers every message with an echo.

Sticker packs

XEP-0449 has no registry of public sticker packs, so the server keeps its own: the sticker catalogue, node urn:xmpp:stickers:0 on the pubsub service pubsub.<domain> (access_model open, persist_items, max_items max, publish_model publishers). Each item is a pack; every client reads the node with plain XEP-0060 and adds a pack from it by its URI, xmpp:pubsub.<domain>?pubsub;action=retrieve;node=urn:xmpp:stickers:0;item=<id> (anotherim's Stickers page, "All stickers", lists it). Two kinds of pack go there:

  • The admin's. libtacky's tackyd stickers publish -acc admin@<domain> -password … -dir DIR -name NAME [-summary TEXT] makes a pack of the images in DIR (each put into upload.<domain>), publishes it on the catalogue, creating the node when it is not there, and prints its URI. Run it from any machine with libtacky's tackyd that reaches the server.
  • Telegram sets. The gateway's ad-hoc command "Import Telegram sticker pack" (node import-stickers on telegram.<domain>) takes a set's short name or its t.me/addstickers/<name> link from a linked user, puts the set's static stickers (at most 120; animated ones are skipped) into upload.<domain> and publishes the pack, answering with its URI; a set imported once answers at once, and from then on that set's stickers from Telegram name their pack, so a tap on one offers it. Nothing is imported unasked.

Whoever creates the node owns it, and only the owner and its publishers may publish. When the gateway creates it, it makes admin@<domain> a publisher too (tackygram's -catalogue-publishers); when the admin's tackyd creates it first, the admin makes telegram.<domain> a publisher of the node (XEP-0060 §8.9, from any client that edits affiliations), or the gateway's imports are refused. pubsub_createnode in ejabberd.yml lets local users and the gateway create nodes.

The packs' files are ordinary uploads, so a lifecycle rule that expires the upload bucket's files (see "Uploads") empties the catalogue's packs with them.

People directory

make env ... DIRECTORY=1 DIRECTORY_COUNTRY_CODE=372 adds the people directory (compose.directory.yaml), libtacky's tackyd directory as the component people.<domain> in ejabberd's network namespace, on the loopback component port with the component secret. ejabberd lists it among the domain's disco items while it is connected; a client finds it there as a directory/user service with jabber:iq:search. Like TACKYGRAM=1, DIRECTORY=1 is given at every make env; env.sh writes state/<domain>/directory/env (0600), and each setting below is the one given, else the one written last time, else libtacky's default:

setting default
DIRECTORY_COUNTRY_CODE the country of a phone number written without one, 1-3 digits none: such a number is taken as international
DIRECTORY_LOCAL_ONLY 1: only this server's users may search; 0: users of every federated server too 1
DIRECTORY_SELF_PHONE 1: a user may list a phone number of their own; 0: only the one the registry gave them 1
DIRECTORY_SELF_CODE the same for the personal code 1
  • Search (XEP-0055, a data form): name (each word starts a word of the listed name, case aside; the longest word at least two letters), phone (exact, after normalising to + and digits; a number written without a country code is taken in DIRECTORY_COUNTRY_CODE, so +372 5123 4567, 37251234567 and 5123 4567 are one number), code (the personal code, exact). The answer gives the address and the name, never the phone or the code; 30 rows at most, nothing for an empty query. Searches are limited per minute to 30 per user, 90 per server and 300 in all, beyond which the answer is resource-constraint; other limits are the program's options, given as the service's command in a compose override (command: [run, -rate, "60", -domain-rate, "300"]).

  • Opt-in. Nobody is listed until they list themselves: an iq <listing xmlns='urn:tacky:directory:0'> with <name/>, <phone/> and <code/> (what is left out is not listed), or the ad-hoc command list on people.<domain>; <unlist/> or the command unlist removes the entry. Only users of this domain list, and only themselves. A personal code or a registry's phone number someone else holds is refused. A self-listed phone or code is a claim nobody checked; with a registry, DIRECTORY_SELF_PHONE=0 and DIRECTORY_SELF_CODE=0 take both from it alone.

  • A registry. For a provisioned population (the state-messenger case), load a CSV of jid,name,phone,code into the running directory:

    make directory-import DOMAIN=chat.example.org CSV=registry.csv
    

    The rows are provisioned: their owners cannot change or remove the name, the code or a phone the registry gave, but may add a phone where it gave none. One bad line fails the whole import, every bad line named.

The store is the compose volume directory, a plain SQLite file (people.db): names, phone numbers and personal codes are not encrypted at rest, so treat the volume as the server's own data. The directory logs counts only, never what was searched or listed. libtacky's tacky/xmpp/directory/README.md has the wire in full.

libtacky has no image, so make directory-image makes one here (directory/Dockerfile): it builds libtacky's tackyd from its source at LIBTACKY_REF, with no media backend, and puts it on Debian's slim base. LIBTACKY_REF= and LIBTACKY_GIT= (another clone) at make directory-image build another libtacky.

The admin

admin@<domain> is the admin. To create the account:

docker compose --env-file state/<domain>/compose.env exec ejabberd ejabberdctl register admin <domain> <password>

Login

Nobody types a JID and a password.

  • Registration. A new account is the proof of work, and the client makes a long random password and keeps it.
  • Sessions. Every device has a FAST token of its own from its first login, and its sessions use that token.
  • The password does two things only:
    • It changes the password. A session that logged in with a token may not (not-allowed), so a device's token is never enough to take the account.
    • It takes the account back: changing the password ends every token.

Planned:

  • Pairing by QR code. A new device shows a one-time key as a QR code. A logged-in device scans it, and a module of ours makes a token for the new device, sealed to that key. The new device collects it and logs in. The password never leaves the first device.
  • Recovery. The password itself, shown once at registration as a code to write down.

Uploads

A client asks upload.<domain> for a slot and gets two URLs:

  • PUT: signed with AWS Signature Version 4 for exactly the size asked (Content-Length is signed, so the store refuses any other size) and good for 10 minutes;
  • GET: where the file will be.

The file never passes through ejabberd. Its key is 128 random bits followed by the file's name; nothing in it names the uploader. The limit is 100 MiB, for local users and for the Telegram gateway, which puts there the stickers its users get, asking as the peer's JID (<id>@telegram.<domain>): the access rule upload in ejabberd.yml grants the ACL telegram_gateway beside local.

The store is any S3 service with a bucket at UPLOAD_URL, reached over HTTPS with the Host header passed through unchanged, since it is signed. It has been tried with RustFS and Garage. The bucket needs:

  • A key for the server. Add EJABBERD_MACRO_S3_ACCESS_KEY= and EJABBERD_MACRO_S3_SECRET= lines to state/<domain>/secrets.env. Until they are there, uploads fail and everything else works; ejabberd refuses an empty value, so make env leaves them out.
  • Anonymous s3:GetObject. The URL is the secret, and OMEMO files are encrypted.
  • A CORS rule allowing GET, PUT and HEAD from any origin, for the web client.
  • A lifecycle rule, if files should expire; not with sticker packs in the catalogue, whose files are uploads too.

Tests

make test           # the modules' tests, in the image build
make test-login     # live: tokens, the password rule, the configuration
make test-acme      # live: the compose deployment with CERTS=acme, against step-ca
make test-push      # live: a push end to end on the compose deployment
make test-schedule  # live: scheduled messages, a restart in between
make test-module-deb  # the module .debs beside ProcessOne's ejabberd .deb
make test-cluster   # live: mod_schedule on two nodes joined as one cluster
make test-client-access  # live: XEP-0494, ann's clients listed and revoked
  • make test. The modules' eunit suites run against the release while the image builds, and fail the build below 99 % of any module's lines covered.

    • mod_apush_tests checks the rules, then every path on a running ejabberd.
    • mod_s3_upload_tests checks the signature against AWS's published example, keys and names, then disco, slots, limits and access (the Telegram gateway's JIDs get a slot, another component's do not).
    • mod_component_iq_tests shows mod_privilege alone dropping the gateway's create on pubsub.<domain>, then the module letting it, a slot request to upload.<domain> and a disco of the domain through unchanged while a wrapped privileged iq is still mod_privilege's.
    • mod_schedule_tests (Mnesia, the default) and mod_schedule_sql_tests (db_type: sql on SQLite), the same cases on each backend, with carbons, MAM, blocking and offline storage: the claim raced by twenty processes (one wins; a releasing item taken again only once stale), disco, every request and refusal, the release (bare JID, carbons to every resource, the archive, the silent marker kept), rooms (MUC/Sub subscribers only, released into the room's archive and its subscribers' events, a room left meanwhile failing the item), errors coming back making an item failed (and others' not), a blocked recipient, an item due while the module was stopped, the timer set again by a submit, many due at once in order, a stored stanza that cannot be read, an account removed.
    • mod_client_access_tests plays c2s processes against ejabberd_sm, mod_auth_fast's and mod_push's stores: a SASL2 client online, hibernated and resumed (one client throughout), FAST tokens of a known agent and of one never seen, push registrations of a client and a stray one, the revoke of a hibernated, a live and the asking session (answered first) with their tokens and registrations, strays revoked, the refusals, the owner only, what is remembered and for how long, a host remembering nothing, the caps node.
  • make test-login checks:

    • a password login gets a FAST token, and the token logs in;
    • the token's session may not change the password, and a password session may;
    • afterwards only the new password works, and not the old token;
    • the domain reached the services: MUC and upload in disco, the STUN/TURN hosts, the admin's commands;
    • proxy.<domain> in the domain's disco#items with the identity proxy/bytestreams, its streamhost the deployment's IP (made a TEST-NET address here, not the container's) and port 7777, refused forbidden to a component of another domain; and two clients, ann and bob, connect to it as SOCKS5, ann activates the stream and bob receives what she sends through it;
    • the component telegram.<domain> gets the gateway's privileges (roster both, message outgoing, presence roster, iq both on urn:xmpp:blocking), and another component name gets none;
    • when the directory's image is there (make directory-image), the directory beside the server with the env make env DIRECTORY=1 writes: people.<domain> in disco as a directory, ann lists herself, bob finds her by name, by phone without its country code and by code (her address only), a one-letter name is refused not-acceptable, bob's listing with her code conflict, a search from another domain forbidden; then, after make env DIRECTORY_LOCAL_ONLY=0 DIRECTORY_SELF_CODE=0 and a restart on the same store, that search finds her and bob's own code is refused not-allowed; once she unlists nobody finds her. Without the image it says skip.
  • make test-acme runs the compose deployment with CERTS=acme through make up, against smallstep's step-ca as the ACME server (with challtestsrv as its DNS) and no ports published. step-ca validates over HTTP-01 on ejabberd's own listener and issues for a week and a minute. Pebble, Let's Encrypt's test server, cannot stand in: it refuses ejabberd's ACME client, which sends no User-Agent, and the client refuses Pebble's charset media types and its data: terms of service; Let's Encrypt itself does neither. It checks:

    • make up asks for the certificate, and ejabberd holds it for the domain and conference;
    • ejabberd serves it for both names without a restart;
    • a second make up asks for nothing;
    • apushd connects;
    • a minute later ejabberd has renewed it by itself and serves the new one.
  • make test-push runs the compose deployment as make env writes it, with a made-up Firebase key given as FCM_KEY (apushd loads it, nothing calls Google), apushd's demo module added beside the Web Push one and no ports published. Before it starts, make env must refuse a key that is not a service account's, keep the copied key when FCM_KEY is left out, and list anotherimAndroid in apushd's settings. ann puts bob in her roster as "Bob" and enables push with a device key, then goes offline, and bob writes to her. It checks:

    • the component port listens on 127.0.0.1 only, and apushd connects with the deployment's secret;
    • apushd's Web Push key and its FCM module's name reach a client through its disco (an apushd with urn:tacky:push:webpush:0 and urn:tacky:push:fcm:0);
    • the envelope apushd receives opens with ann's key to a summary naming Bob as her roster does, for ann, counting one, without the text;
    • the push window (5 seconds here): three messages at once are one push counting two and one more at the window's end counting four;
    • a mentions-only room ann is subscribed to (MucSub), her occupant id from its self-presence: bob's plain message there is no push, his XEP-0513 mention of her occupant id is one, its summary saying "mention": true.
    • other clients' registrations, at a stand-in App Server (a component, p2.): one with a secret only, as Conversations enables, and one with no form; bob's call and his message each reach both as mod_push makes the push, the publish-options as sent and nothing of mod_apush's, while ann's apush registration gets the message alone, sealed as before.
  • make test-schedule runs the image as make env writes it, ann and bob registered, bob offline. It checks:

    • the domain advertises urn:tacky:schedule:0 with its limits;
    • ann schedules six messages, five to bob; one for telegram.<domain> is redirected to the gateway (the configuration's @DOMAIN@ reached pass_through), one past a year is too-far;
    • bob's chat lists the five in time order as from ann's bare JID, the silent one silent; a change of time and text, a cancel and a send now are answered, and every change reaches her as a <changed/> headline;
    • each release reaches her as a sent carbon from her bare JID (the silent one with its marker, the edited one with the new text) and her archive keeps the four; one to an account that does not exist comes back failed with service-unavailable and stays listed;
    • bob, catching up from his archive, has the four from ann's bare JID and not the cancelled one;
    • a message due while the server is stopped goes when it starts again, into both archives;
    • a groupchat message to a room ann is a MUC/Sub subscriber of is released into it (the room's archive, her device's MucSub event), and one to a room she is not subscribed to is refused not-allowed at once.
  • make test-module-deb builds the four module packages (make module-deb: ejabberd-mod-apush, ejabberd-mod-component-iq, ejabberd-mod-schedule and ejabberd-mod-client-access, for a server on ProcessOne's own ejabberd 26.09-1 .deb) and installs them beside that .deb, installed with apt on Debian trixie, then adds what their READMEs ask to the package's default configuration (no database to set up). It checks each installs and depends on exactly 26.09-1, ejabberd starts and each module is loaded, and mod_schedule works there on Mnesia (the feature, a message released, the carbon, bob's archive, the Telegram redirect through @HOST@), and mod_client_access there (with mod_auth_fast added, as its README says): the hibernated phone with its token listed, revoked (no token, no resumption), a live session's stream ended, bob refused.

  • make test-client-access runs the image as make env writes it, ann's devices each a SASL2 user agent naming its device. It checks:

    • her account's disco#info lists urn:xmpp:cam:0 to her;
    • her laptop lists four clients: the phone hibernated (its connection lost under XEP-0198) and still connected, with a FAST token; the tablet not connected, with a token; the desk and the laptop online, the laptop with its push registration; live sessions with address and resource;
    • revoking the phone ends its token (refused) and its hibernation (a password login asking to resume it is told failed); revoking the tablet its token; revoking the desk ends its stream with a policy-violation stream error; an unknown id is item-not-found;
    • the list after holds the laptop and the phone's new password login;
    • bob may neither list nor revoke her clients; the laptop's own token still works.
  • make test-cluster runs two containers of the image as the nodes ejabberd@node-a and ejabberd@node-b on one network, B joined to A with ejabberdctl join_cluster. It checks the schedule table has a disc copy on both; an item submitted on A is listed on B; with A stopped before its time, B sends it; with both up an item goes once; and an item due while the whole cluster was down goes once when both start again at the same moment. Once is counted in bob's offline store (Mnesia, the cluster's) and in ann's archives (each node's own SQLite here: a double send would show twice).

tests/xmpp-client.py is the live tests' client, written with Python's standard library only.