# 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): ```yaml 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`: ```nginx 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: ```html
``` 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): ```lua 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: ```python 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: ```bash 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).