1
0
Fork 0
forked from lukas/nixos-config
nixos-config/hosts/pinix/localServices/prosody/README.md
pijukka 894ede6742 agent(xmpp): persist Codex sessions
Debounce short message bursts, resume sessions per sender, add /neu reset handling, and document the operational workflow.
2026-07-30 00:08:28 +02:00

276 lines
9.6 KiB
Markdown

# 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 <SESSION_ID>`. 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 2>/dev/null |
openssl x509 -noout -subject -issuer -dates -fingerprint -sha256
```