Files
xmpp-server-prosody/references/webchat-conversejs-and-caddy-trap.md
2026-09-06 13:51:11 +00:00

4.8 KiB

Web client (Converse.js) behind Caddy + Caddy bind-mount trap

Session detail from adding the web client to the ICQ XMPP project (2026-08-28).

Converse.js web client architecture

Serve the web client with a tiny nginx container that BOTH serves static Converse.js AND proxies the WebSocket to Prosody — so Caddy needs exactly ONE upstream (nginx:8081), not two.

docker-compose.yml addition (same compose project as prosody, so prosody resolves by container name on the shared network):

  webchat:
    image: nginx:alpine
    container_name: icq-webchat
    restart: unless-stopped
    volumes:
      - ./webchat/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./webchat:/usr/share/nginx/html:ro
    ports:
      - "8081:8081"
    depends_on:
      - prosody

webchat/nginx.conf:

server {
    listen 8081;
    server_name chat.nixg.ru;
    root /usr/share/nginx/html;
    index index.html;
    location /xmpp-websocket {
        proxy_pass http://prosody:5280/xmpp-websocket;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
    location / { try_files $uri $uri/ /index.html; }
}

webchat/index.html — Converse.js from CDN:

<link rel="stylesheet" href="https://cdn.conversejs.org/css/converse.min.css">
<div id="conversejs"></div>
<script src="https://cdn.conversejs.org/dist/converse.min.js"></script>
<script>
converse.initialize({
    bosh_service_url: 'wss://chat.nixg.ru/xmpp-websocket',
    theme: 'concord',
    i18n: 'ru',
    view_mode: 'overlayed',  // or 'fullscreen'
    allow_file_transfer: false,  // needs http_upload component
});
</script>

Caddy block (point at nginx, NOT directly at Prosody 5280):

chat.nixg.ru {
    reverse_proxy 10.8.0.2:8081 {
        header_up Host {host}
        header_up X-Forwarded-Proto https
    }
}

mod_websocket handshake requirements (diagnosing 501/403)

  • A plain curl GET to /xmpp-websocket returns a generic "It works! Now point your WebSocket client to this URL" page — that does NOT prove WebSocket works. You must send a real WebSocket handshake.

  • Sec-WebSocket-Protocol: xmpp is REQUIRED. Without it Prosody answers 501 Not Implemented ("Client didn't want to talk XMPP" — mod_websocket.lua ~line 217).

  • With the protocol header but no allowed Origin, Prosody answers 403 Forbidden (cross-domain check, mod_websocket.lua ~line 225). Fix: GLOBAL config section (above VirtualHost):

    cross_domain_websocket = { "https://chat.nixg.ru" }
    

    then restart prosody. Browser clients ALWAYS send Origin, so this must be set.

  • curl cannot do a real WS handshake — its 501/400/403 results are expected noise, NOT proof of breakage. Test with a raw-socket handshake script:

import socket, base64, os
s = socket.create_connection(("127.0.0.1", 8081), timeout=6)
key = base64.b64encode(os.urandom(16)).decode()
req = (f"GET /xmpp-websocket HTTP/1.1\r\nHost: chat.nixg.ru\r\n"
       "Upgrade: websocket\r\nConnection: Upgrade\r\n"
       f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n"
       "Sec-WebSocket-Protocol: xmpp\r\nOrigin: https://chat.nixg.ru\r\n\r\n")
s.sendall(req.encode())
resp = b""
while b"\r\n\r\n" not in resp:
    resp += s.recv(4096)
print(resp.split(b"\r\n")[0].decode())  # expect HTTP/1.1 101 Switching Protocols
s.close()

Caddy bind-mount inode trap (edit via tee/redirect silently ignored)

Editing /opt/caddy/Caddyfile on the host with sudo tee or > redirect creates a NEW inode. The running caddy container keeps the OLD inode bound (bind mount), so:

  • docker exec caddy caddy reload --config /etc/caddy/Caddyfile SUCCEEDS but serves the OLD config.
  • grep of the file INSIDE the container differs from the file ON the host (different inode & size via stat).

Fix: docker compose restart caddy (rebinds the mount to the new inode), then verify by grepping the file inside the container.

Diagnostic that revealed it:

sudo stat -c "%i %s %y" /opt/caddy/Caddyfile          # host inode
sudo docker exec caddy stat -c "%i %s %y" /etc/caddy/Caddyfile  # container inode — differs!
# also: caddy adapt --config ... | grep upstreams shows the OLD dial IP

Security group / provider firewall (Timeweb-style)

  • Cloud providers (Timeweb etc.) default-close non-standard ports. Open TCP 5222 + 5269 in the provider's security group for the public VPS. XMPP uses TCP only — no UDP.
  • When creating a security group, keep the broad egress rule (Any/Any/0.0.0.0/0); if the group only allows metadata-IP egress, the VPS loses general internet (this was avoided — the xmpp group was ADDITIONAL to the base group, so egress stayed open).