# Prosody Prosody runs on `pinix`. Public XMPP traffic reaches port 5222 on `blunix`, where Traefik forwards it through the Headscale/Tailscale network to `100.64.0.3:5222`. The service uses the upstream NixOS Prosody module. LDAP authentication is enabled by adding `lualdap` to the Prosody package's Lua environment in `setup-prosody.nix`; no store paths or copied NixOS module are required. The LDAP password and MOTD are included from agenix files at runtime. Consequently, the module's build-time configuration check is disabled and the live configuration must be checked after activation. ## Anonymous web chat `xmpp-web.nix` serves the pinned xmpp-web release on pinix. nginx terminates TLS on the Tailscale address `100.64.0.3:8443`, serves the static frontend, and proxies `/xmpp-websocket` to Prosody on `127.0.0.1:5280`. On blunix, `traefik-xmpp-web.nix` uses TCP TLS passthrough for `chat.0ff.space`. TLS is therefore terminated on pinix; blunix neither receives the certificate key nor sees XMPP message contents. Guests authenticate anonymously against `anon.0ff.space` and join `lobby@muc.0ff.space`. The room is persistent and `lukas@0ff.space` is its owner, so the normal LDAP-backed account can join and administer it from Monal without a second account. The MUC archive is retained for 30 days and cleaned every four hours. `mod_muc_moderation` is installed from the Prosody community-modules revision pinned by nixpkgs and enabled only on `muc.0ff.space`. Clients supporting XEP-0425 can therefore retract abusive messages from the room and its archive; the moderator must join with an owner or administrator account. ## Update notifications Forgejo sends push and pull-request events to the local webhook receiver on pinix. The receiver posts short notifications as `forgeBot` to `update@muc.0ff.space` using `vikunja@bot.0ff.space`. The repository hook targets `http://127.0.0.1:9000/hooks/wiki-update`; Forgejo permits that exact loopback address in addition to its default external targets. The update room is members-only. The bot therefore needs a `member` affiliation in the room; its password remains in `go-sendxmpp.age`. ### Second channel: `agent@bot.0ff.space` Vikunja updates additionally go out as a direct (1:1) message from `vikunja` to `agent@bot.0ff.space`, alongside the existing `vikunja@muc.0ff.space` post. Unlike a MUC room, a 1:1 JID needs no room creation or affiliation dance — Prosody delivers chat messages between local accounts regardless of roster subscription. The point of the separate account is to have a single-JID message archive that only carries this one event stream, instead of parsing it back out of the shared MUC room log. `agent@bot.0ff.space` is intentionally never added to a human XMPP client: `go-sendxmpp` sends plaintext (no OMEMO), so a client like Monal shows this conversation unencrypted next to otherwise-encrypted chats. The listener now runs on blunix, where the persistent Codex checkout and CLI authentication already live. `hosts/blunix/localServices/agent-codex.nix` keeps `go-sendxmpp --listen` connected as `agent`, archives its stdout in the journal, and only queues messages from the explicit sender allowlist: ```bash journalctl -u agent-xmpp-listener ``` Accepted messages are atomically written below `/var/lib/agent-codex/inbox`. Messages from the same sender that arrive within a three-second quiet window are combined into one queue job. Sending `/neu` is handled immediately after any already pending text and resets the sender's active conversation. `agent-codex-worker.path` starts a separate oneshot worker, which invokes `codex exec` in `/home/drone/nixos-config/agents/platform`. The nested `agents/platform/AGENTS.md` supplies the XMPP role in addition to the root repository instructions. The first turn uses JSONL output to capture Codex's `thread.started` ID. Successful replies store the sender-to-thread mapping below `/var/lib/agent-codex/sessions`; later turns use `codex exec resume `. A mapping expires after 30 minutes without a successful turn. `/neu` removes it explicitly, so the next content message starts a fresh thread. Codex's own session rollout files remain in the isolated `/var/lib/agent-codex/codex-home`. Every invocation ignores personal Codex configuration, uses the isolated `CODEX_HOME` containing only the copied authentication, explicitly selects a read-only sandbox, and sends only the final agent message back to the original XMPP sender. systemd serializes the queue; tmux is not involved. Failed jobs remain in `/var/lib/agent-codex/failed` for inspection. The first successful reply to each allowlisted sender includes a one-time usage note. In normal use: - send related short fragments within three seconds to combine them; - wait for the reply before beginning a different request; - send `/neu` to discard the current conversational context; - wait 30 minutes to let the active mapping expire automatically. The account password lives in `agentXmpp.age`, decrypted at runtime to `/run/agenix/agentXmpp` on blunix, owner `drone`. It is not decrypted on pinix after the listener migration. The listener reads it through a mode-`0600` runtime configuration, so it is not exposed in process arguments. Useful checks on blunix: ```bash systemctl status agent-xmpp-listener agent-codex-worker.path journalctl -u agent-xmpp-listener -u agent-codex-worker find /var/lib/agent-codex -maxdepth 2 -type f ``` Run the unit tests from the repository root: ```bash nix shell --inputs-from . nixpkgs#python3 --command \ python3 -m unittest discover \ -s hosts/blunix/localServices/agent-codex \ -p 'test_*.py' -v ``` ## Enable and deploy Enable the Prosody configuration in `../default.nix`: ```nix ./prosody/setup-prosody.nix ``` Build locally on `tnix`, copy the closure to `pinix`, and activate it: ```bash nrsp ``` ## Refresh the TLS certificate Traefik renews the wildcard Let's Encrypt certificate on `blunix`, but Prosody reads separate certificate files on `pinix`. Copy the current certificate manually before the previous one expires. Let's Encrypt certificates are normally valid for about 90 days. Run all commands from `tnix`. ### 1. Check source and installed certificates Check the current Traefik certificate without exposing the private key: ```bash ssh -T blunix \ 'sudo jq -r '"'"'.cloudflare.Certificates[] | select(.domain.main == "0ff.space") | .certificate'"'"' \ /var/lib/traefik/acme.json | base64 -d | openssl x509 -noout -subject -issuer -dates -fingerprint -sha256' ``` Check the certificate currently installed on `pinix`: ```bash ssh -T pinix \ 'sudo openssl x509 \ -in /var/lib/prosody/certs/0ff.space.crt.pem \ -noout -subject -issuer -dates -fingerprint -sha256' ``` ### 2. Transfer certificate and key Transfer both files directly over SSH. The private key is never written to the `tnix` filesystem or printed to the terminal. ```bash set -o pipefail ssh -T blunix \ 'sudo jq -r '"'"'.cloudflare.Certificates[] | select(.domain.main == "0ff.space") | .certificate'"'"' \ /var/lib/traefik/acme.json | base64 -d' | ssh -T pinix \ 'sudo sh -c '"'"'umask 022 tee /var/lib/prosody/certs/0ff.space.crt.pem.new >/dev/null chown prosody:prosody /var/lib/prosody/certs/0ff.space.crt.pem.new'"'"'' ``` ```bash set -o pipefail ssh -T blunix \ 'sudo jq -r '"'"'.cloudflare.Certificates[] | select(.domain.main == "0ff.space") | .key'"'"' \ /var/lib/traefik/acme.json | base64 -d' | ssh -T pinix \ 'sudo sh -c '"'"'umask 077 tee /var/lib/prosody/certs/0ff.space.key.pem.new >/dev/null chown prosody:prosody /var/lib/prosody/certs/0ff.space.key.pem.new'"'"'' ``` ### 3. Validate and install Validate the certificate, private key, and file permissions on `pinix`: ```bash ssh -T pinix 'sudo sh -c '"'"' set -eu cert=/var/lib/prosody/certs/0ff.space.crt.pem.new key=/var/lib/prosody/certs/0ff.space.key.pem.new openssl x509 -in "$cert" -noout -dates -fingerprint -sha256 openssl pkey -in "$key" -check -noout cert_hash=$(openssl x509 -in "$cert" -pubkey -noout | openssl pkey -pubin -outform DER 2>/dev/null | sha256sum | cut -d" " -f1) key_hash=$(openssl pkey -in "$key" -pubout -outform DER 2>/dev/null | sha256sum | cut -d" " -f1) test "$cert_hash" = "$key_hash" stat -c "%U:%G %a %n" "$cert" "$key" '"'"'' ``` Expected permissions: ```text prosody:prosody 644 ...crt.pem.new prosody:prosody 600 ...key.pem.new ``` Back up the existing files, install the validated files, and restart Prosody: ```bash ssh -T pinix 'sudo sh -c '"'"' set -eu dir=/var/lib/prosody/certs stamp=$(date -u +%Y%m%dT%H%M%SZ) cp -a "$dir/0ff.space.crt.pem" \ "$dir/0ff.space.crt.pem.previous-$stamp" cp -a "$dir/0ff.space.key.pem" \ "$dir/0ff.space.key.pem.previous-$stamp" mv "$dir/0ff.space.crt.pem.new" "$dir/0ff.space.crt.pem" mv "$dir/0ff.space.key.pem.new" "$dir/0ff.space.key.pem" chmod 640 "$dir/0ff.space.key.pem" systemctl restart prosody systemctl restart nginx '"'"'' ``` The installed key is `0640 prosody:prosody`: nginx is a supplementary member of the `prosody` group and receives read-only access. The temporary key remains `0600` while it is being transferred and validated. ### 4. Verify Check the service and listeners: ```bash ssh -T pinix \ 'systemctl is-active prosody ss -lnt | grep -E ":(5222|5269|5280|5281)[[:space:]]"' ``` Verify the certificate delivered through the public XMPP endpoint: ```bash set -o pipefail timeout 20 openssl s_client \ -starttls xmpp \ -connect 23.88.33.137:5222 \ -xmpphost 0ff.space \ -servername 0ff.space \ /dev/null | openssl x509 -noout -subject -issuer -dates -fingerprint -sha256 ```