# Lotus Matrix Infrastructure [![Lint](https://code.lotusguild.org/LotusGuild/matrix/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/matrix/actions?workflow=lint.yml) Matrix server infrastructure for the Lotus Guild homeserver (`matrix.lotusguild.org`). **Repo**: https://code.lotusguild.org/LotusGuild/matrix --- ## Repo Structure ``` matrix/ ├── hookshot/ # Hookshot JS transformation functions (one file per webhook) │ ├── deploy.sh # Deploys all .js files to Matrix room state via API │ ├── proxmox.js │ ├── grafana.js │ ├── uptime-kuma.js │ └── ... # One .js per webhook service ├── cinny/ │ ├── config.json # Cinny homeserver config (deployed to /var/www/html/config.json) │ ├── upstream-check.sh # Daily script: checks if cinnyapp/cinny main has new commits, pings Matrix │ └── lotus-build.sh # Merge + build script: fetches upstream/main, merges, builds, deploys ├── landing/ │ └── index.html # matrix.lotusguild.org landing page ├── draupnir/ │ └── production.yaml # Draupnir config (access token is redacted — see rotation docs below) ├── deploy/ # Auto-deployment infrastructure │ ├── lxc151-hookshot.sh # Deploy script for LXC 151 (matrix/hookshot/livekit) │ ├── lxc106-cinny.sh # Deploy script for LXC 106 (cinny) │ ├── lxc139-landing.sh # Deploy script for LXC 139 (landing page) │ ├── lxc110-draupnir.sh # Deploy script for LXC 110 (draupnir) │ ├── livekit-graceful-restart.sh # Waits for zero active calls before restarting livekit │ ├── hooks-lxc151.json # webhook binary config for LXC 151 │ ├── hooks-lxc106.json # webhook binary config for LXC 106 │ ├── hooks-lxc139.json # webhook binary config for LXC 139 │ └── hooks-lxc110.json # webhook binary config for LXC 110 └── systemd/ ├── livekit-server.service # LiveKit systemd unit (with HA migration fix) ├── livekit-graceful-restart.service # oneshot — checks pending restart flag ├── livekit-graceful-restart.timer # Runs every 5 min ├── draupnir.service └── cinny-upstream-check.cron # Installed to /etc/cron.d/ on LXC 106 — runs daily at noon ``` --- ## Infrastructure | Service | IP | LXC | RAM | Disk | Versions | |---------|----|-----|-----|------|----------| | Synapse | 10.10.10.29 | 151 | 8GB | 50GB | Synapse 1.155.0, LiveKit 1.9.11, hookshot 7.3.2, coturn latest | | PostgreSQL 17 | 10.10.10.44 | 109 | 6GB | 30GB | PostgreSQL 17.9 | | Cinny Web | 10.10.10.6 | 106 | 2GB | 8GB | Debian 12, nginx, Node 24, Lotus Cinny fork (custom, tracks `cinnyapp/cinny` main) | | Draupnir | 10.10.10.24 | 110 | 1GB | 10GB | Draupnir v2.9.0, Node.js v22 | | Prometheus | 10.10.10.48 | 118 | — | — | Prometheus — scrapes all Matrix services | | Grafana | 10.10.10.49 | 107 | — | — | Grafana 12.4.0 — dashboard.lotusguild.org | | NPM | 10.10.10.27 | 139 | — | — | Nginx Proxy Manager + matrix landing page | | Authelia | 10.10.10.36 | 167 | — | — | SSO/OIDC provider | | LLDAP | 10.10.10.39 | 147 | — | — | LDAP user directory | | Uptime Kuma | 10.10.10.25 | 101 | — | — | Uptime monitoring (micro1 node) | **Key paths on Synapse LXC (151):** - Synapse config: `/etc/matrix-synapse/homeserver.yaml` - Synapse conf.d: `/etc/matrix-synapse/conf.d/` (metrics.yaml, report_stats.yaml, server_name.yaml) - coturn config: `/etc/turnserver.conf` - LiveKit config: `/etc/livekit/config.yaml` - LiveKit service: `livekit-server.service` - lk-jwt-service: `lk-jwt-service.service` (now binds `:8071` via drop-in `/etc/systemd/system/lk-jwt-service.service.d/override.conf`; serves JWT tokens for MatrixRTC at `/sfu/get` and legacy `/get_token`) - voice-limit-guard: `voice-limit-guard.service` (binds `:8070`, fronts lk-jwt-service — enforces hard per-room voice participant limits **and publish permissions (screenshare/camera via JWT re-signing)** for ALL clients; script `/opt/voice-limit-guard/voice-limit-guard.py`) — see [Voice Channel Limits & Call Permissions](#voice-channel-limits--call-permissions) - Hookshot: `/opt/hookshot/`, service: `matrix-hookshot.service` - Hookshot config: `/opt/hookshot/config.yml` - Hookshot registration: `/etc/matrix-synapse/hookshot-registration.yaml` - Bot: `/opt/matrixbot/`, service: `matrixbot.service` - Repo clone (auto-deploy): `/opt/matrix-config/` - Deploy env: `/etc/matrix-deploy.env` (MATRIX_TOKEN, MATRIX_SERVER, MATRIX_ROOM) - Deploy log: `/var/log/matrix-deploy.log` **Key paths on Draupnir LXC (110):** - Install path: `/opt/draupnir/` - Config: `/opt/draupnir/config/production.yaml` - Data/SQLite DBs: `/data/storage/` - Service: `draupnir.service` - Management room: `#management:matrix.lotusguild.org` (`!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI`) - Bot account: `@draupnir:matrix.lotusguild.org` (power level 100 in all protected rooms and the Lotus Guild space) - Subscribed ban lists: `#community-moderation-effort-bl:neko.dev`, `#matrix-org-coc-bl:matrix.org` - Rebuild: `NODE_OPTIONS="--max_old_space_size=6144" npm run build` - Healthz endpoint: `http://10.10.10.24:8081/healthz` (200 = healthy, 418 = disconnected) - Abuse reporting endpoint: `POST http://10.10.10.24:8080/_matrix/draupnir/1/report/{roomId}/{eventId}` - Audit DBs: `/data/storage/user-restriction-audit-log.db`, `/data/storage/room-audit-log.db` **Key paths on PostgreSQL LXC (109):** - PostgreSQL config: `/etc/postgresql/17/main/postgresql.conf` - Tuning conf.d: `/etc/postgresql/17/main/conf.d/synapse_tuning.conf` - HBA config: `/etc/postgresql/17/main/pg_hba.conf` - Data directory: `/var/lib/postgresql/17/main` **Key paths on Cinny LXC (106):** - Lotus fork source: `/opt/lotus-cinny/` (fork of `cinnyapp/cinny` main, custom Lotus Guild branch) - Upstream remote: `https://github.com/cinnyapp/cinny.git` (added as `upstream`) - Built files: `/var/www/html/` - Cinny config: `/var/www/html/config.json` - Config backup (survives rebuilds): `/opt/lotus-cinny/.cinny-config.json` - Monitor env: `/etc/cinny-monitor.env` (MATRIX_TOKEN, MATRIX_SERVER, MATRIX_ROOM, MATRIX_PING_USER — not in git) - Upstream check script: `/usr/local/bin/cinny-upstream-check.sh` - Build/deploy script: `/usr/local/bin/cinny-build.sh` (triggered by webhook or manual run) - Cron: `/etc/cron.d/cinny-upstream-check` (runs at noon daily — checks only, does not auto-build) - Monitor state: `/var/lib/cinny-monitor/last-upstream-commit` - Monitor log: `/var/log/cinny-monitor.log` - Build log: `/var/log/cinny-build.log` - Nginx site config: `/etc/nginx/sites-available/cinny` --- ## Auto-Deployment Pushes to `main` on `LotusGuild/matrix` automatically deploy to the relevant LXC(s) via Gitea webhooks. All 4 LXCs are fully independent — each runs its own webhook listener and deploys only its own files. No cross-LXC SSH dependencies. ### How It Works 1. Push to `LotusGuild/matrix` on Gitea 2. Gitea fires webhooks to all 4 LXCs simultaneously (HMAC-SHA256 validated) 3. Each LXC runs `/usr/local/bin/matrix-deploy.sh` via the `webhook` binary 4. Script does `git fetch + reset --hard origin/main`, checks which files changed, deploys only relevant ones 5. Logs to `/var/log/matrix-deploy.log` on each LXC ### Per-LXC Webhook Endpoints | LXC | Service | IP | Port | Deploys When Changed | |-----|---------|----|----|----------------------| | 151 | matrix/hookshot | 10.10.10.29 | **9500** | `hookshot/*.js`, `systemd/livekit-server.service`, `livekit/voice-limit-guard.py`, `systemd/voice-limit-guard.service`, `matrixbot/*` | | 106 | cinny | 10.10.10.6 | 9000 | `cinny/config.json`, `cinny/upstream-check.sh`, `cinny/lotus-build.sh`, `deploy/hooks-lxc106.json`, `systemd/cinny-upstream-check.cron` | | 139 | landing/NPM | 10.10.10.27 | 9000 | `landing/index.html` | | 110 | draupnir | 10.10.10.24 | 9000 | `draupnir/production.yaml` | > LXC 151 uses port **9500** because ports 9000–9004 are occupied by Synapse and Hookshot. ### What Each Deploy Does **LXC 151 — hookshot/livekit:** - `hookshot/*.js` changed → runs `hookshot/deploy.sh` (pushes transform functions to Matrix room state via API, requires `MATRIX_TOKEN` in `/etc/matrix-deploy.env`) - `systemd/livekit-server.service` changed → copies file, `daemon-reload`, sets `/run/livekit-restart-pending` flag (actual restart deferred — see Livekit Graceful Restart below) - `livekit/voice-limit-guard.py` / `systemd/voice-limit-guard.service` changed → `py_compile`-validates, installs to `/opt/voice-limit-guard/`, `daemon-reload` (if unit changed), and restarts `voice-limit-guard` (restart only affects joins in a ~1s window; established calls talk directly to livekit-server, so no call is dropped) **LXC 106 — cinny:** - `cinny/config.json` → copies to `/var/www/html/config.json` - `cinny/upstream-check.sh` → copies to `/usr/local/bin/cinny-upstream-check.sh`, `chmod +x` - `cinny/lotus-build.sh` → copies to `/usr/local/bin/cinny-build.sh`, `chmod +x` - `deploy/hooks-lxc106.json` → copies to `/etc/webhook/hooks.json`, restarts `webhook` service - `systemd/cinny-upstream-check.cron` → copies to `/etc/cron.d/cinny-upstream-check`, `chmod 644` **LXC 139 — landing page:** - `landing/index.html` → copies to `/var/www/matrix-landing/index.html`, `nginx -s reload` **LXC 110 — draupnir:** - `draupnir/production.yaml` → extracts live `accessToken` from existing config, overwrites from repo, restores token via `sed`, restarts `draupnir.service` ### Installed Components (per LXC) - `webhook` binary (Debian package `webhook` v2.8.0) listening on respective port - `/etc/webhook/hooks.json` — unique HMAC-SHA256 secret per LXC - `/usr/local/bin/matrix-deploy.sh` — deploy script from this repo - `/etc/systemd/system/webhook.service` — enabled and running - `/opt/matrix-config/` — clone of this repo - `/var/log/matrix-deploy.log` — deploy log **LXC 151 additionally:** - `/etc/matrix-deploy.env` — `MATRIX_TOKEN`, `MATRIX_SERVER`, `MATRIX_ROOM` (not in git) - `/usr/local/bin/livekit-graceful-restart.sh` - `/etc/systemd/system/livekit-graceful-restart.service` + `.timer` **LXC 106 additionally:** - `/etc/cinny-monitor.env` — `MATRIX_TOKEN`, `MATRIX_SERVER`, `MATRIX_ROOM`, `MATRIX_PING_USER` (not in git) - `/var/lib/cinny-monitor/last-upstream-commit` — state file (tracks last-seen upstream SHA) - `/opt/lotus-cinny/` — git clone of `code.lotusguild.org/LotusGuild/cinny` with `upstream` remote (`cinnyapp/cinny`) - `/root/.git-credentials` — Gitea token `lxc106-lotus-cinny` (write:repository scope, revocable via Gitea UI) - `/var/lib/cinny-monitor/last-upstream-tag` — last seen stable release tag (e.g. `v4.11.1`) ### Livekit Graceful Restart Killing livekit-server while a call is active drops everyone. Instead: 1. Deploy to LXC 151 copies the new `livekit-server.service` and sets a `/run/livekit-restart-pending` flag 2. `livekit-graceful-restart.timer` runs every 5 minutes 3. The timer script counts established TCP connections on port 7881 (`ss -tn state established`) 4. If zero connections → restarts livekit-server and clears the flag 5. If connections exist → logs and exits, retries in 5 minutes --- ## Voice Channel Limits & Call Permissions Per-room voice **participant caps** and **publish permissions** (screenshare / camera) are enforced **server-side for every client** (Element, FluffyChat, Lotus Chat, …), not just our own web client. Both are enforced by the same `voice-limit-guard` sidecar (`livekit/voice-limit-guard.py`), which fronts lk-jwt-service at token issue. **How it works** Every Matrix client must fetch a LiveKit JWT from lk-jwt-service before it can join a call. `voice-limit-guard` (a small fail-open Python sidecar) sits in front of that service: - lk-jwt-service was moved off `:8070` to `:8071` (systemd drop-in). The guard now owns `:8070`, so NPM's existing `/sfu/get` + `/get_token` proxy targets are unchanged. - On each token request the guard reads the room's Lotus policy from Synapse admin state (one `/state` fetch, cached 10 s): `io.lotus.voice_limit` → `max_users`, and `io.lotus.room_quality` → `allow_screenshare` / `allow_camera`. The room id is taken from the **endpoint's own field** (`/get_token` → `room_id`, `/sfu/get` → `room`) exactly as lk-jwt-service reads it, so a client sending both keys can't get a different room's policy applied than the token is minted for. - **Participant limit** — it forwards to lk-jwt-service, and if a token is issued decodes the JWT to get the LiveKit alias (`video.room`) + requester (`sub`), then asks LiveKit `ListParticipants` how many **distinct Matrix users** are in the room. requester already present (rejoin) → allow · distinct users ≥ limit → **403** · otherwise → allow. - **Publish permissions (screenshare / camera)** — LiveKit is a pure SFU and **cannot cap a publisher's bitrate/framerate** (no such field exists in the grant/config/API — that stays a Lotus-client-cooperative setting). But the JWT's `video.canPublishSources` **is** SFU-enforced for every client. Since the guard holds the LiveKit signing secret, when a room forbids a source it **decodes the issued token, drops `screen_share`/`screen_share_audio` (and/or `camera`) from `canPublishSources`, and re-signs it** (HS256, same key). Microphone is always kept. The SFU then rejects those tracks for **all** clients — nothing to opt into. - **Live (mid-call) enforcement** — the JWT re-sign covers anyone *joining* after a policy change. For people **already in the call**, a background **reconcile loop** (every `GUARD_RECONCILE_INTERVAL`, default 3 s) calls LiveKit `UpdateParticipant` to narrow their `canPublishSources`, which **unpublishes an in-progress screenshare/camera server-side for all clients** and blocks re-publish (confirmed LiveKit 1.9.11 behavior: reducing `can_publish_sources` removes the offending live track). So flipping a room to audio-only kills existing cameras/screenshares within ~one interval. The loop learns each LiveKit room's Matrix id from tokens it issues, only ever **removes** forbidden sources (never grants), preserves every other permission flag (full-replace safety), and no-ops once compliant. Disable with `GUARD_RECONCILE=0`. - **Fail-open:** any error (admin API down, bad/absent token, LiveKit unreachable, unparseable room id, unexpected JWT shape) returns the upstream response **unchanged**, so calls keep working even if enforcement is degraded. The limit check and the source-policy re-sign are **independent** (a LiveKit-admin outage during the limit count can't skip the source restriction, and vice-versa). Before re-signing, the guard **verifies its own secret actually signed the token** — on a `LIVEKIT_SECRET` mismatch it skips the restriction and passes the original token through (so a secret drift can never emit a token the SFU rejects). A room with no policy set takes a zero-overhead fast path (token untouched). > **Security note:** `LIVEKIT_KEY`/`LIVEKIT_SECRET` are currently hardcoded in `systemd/voice-limit-guard.service` (pre-existing). Since this secret now also signs re-issued join tokens, it should be moved into `/etc/matrix-deploy.env` (already an `EnvironmentFile` on LXC 151) and the exposed value rotated. Not changed automatically to avoid a deploy breaking before the env file carries it. Pure logic (limit decision, source narrowing, JWT re-sign/verify roundtrip, tamper detection) is unit-tested in `livekit/test_voice_limit_guard.py` (`python3 -m unittest livekit.test_voice_limit_guard`). **Setting policy:** room admins use Lotus Chat → Room Settings → General → **Voice** (Call Permissions switches + Quality Caps). Any tool that can send room state works too: ```bash # max 5 participants; send {} to remove the limit curl -X PUT -H "Authorization: Bearer " -H "Content-Type: application/json" \ "https://matrix.lotusguild.org/_matrix/client/v3/rooms//state/io.lotus.voice_limit/" \ -d '{"max_users": 5}' # forbid screenshare + make it audio-only (hard, all clients); numeric caps are # Lotus-client-cooperative hints in the same event curl -X PUT -H "Authorization: Bearer " -H "Content-Type: application/json" \ "https://matrix.lotusguild.org/_matrix/client/v3/rooms//state/io.lotus.room_quality/" \ -d '{"allow_screenshare": false, "allow_camera": false, "audio_max_kbps": 32}' ``` **Config:** the guard reads `MATRIX_TOKEN` (server-admin) from `/etc/matrix-deploy.env`; LiveKit key/secret + ports are set in `systemd/voice-limit-guard.service`. **Deploy:** auto-deploys on push (LXC 151 handler `py_compile`-validates then restarts the guard). Manual (re)deploy / first-time setup: ```bash # On LXC 151 install -D -m644 /opt/matrix-config/livekit/voice-limit-guard.py /opt/voice-limit-guard/voice-limit-guard.py install -m644 /opt/matrix-config/systemd/voice-limit-guard.service /etc/systemd/system/voice-limit-guard.service # one-time: rebind lk-jwt-service to :8071 mkdir -p /etc/systemd/system/lk-jwt-service.service.d printf '[Service]\nEnvironment=LIVEKIT_JWT_BIND=:8071\n' > /etc/systemd/system/lk-jwt-service.service.d/override.conf systemctl daemon-reload && systemctl restart lk-jwt-service && systemctl enable --now voice-limit-guard ``` **To fully revert** (back to lk-jwt-service directly on `:8070`): `systemctl disable --now voice-limit-guard`, remove the drop-in, `daemon-reload`, `systemctl restart lk-jwt-service`. --- ## Access Token Rotation The `MATRIX_TOKEN` in `/etc/matrix-deploy.env` on LXC 151 is a Jared user token used to push hookshot transforms to Matrix room state (requires power level ≥ 50 in Spam and Stuff). The token in `draupnir/production.yaml` in this repo is **intentionally redacted** (`accessToken: REDACTED`). The deploy script on LXC 110 extracts the live token from the running config before overwriting from the repo, then restores it. **To rotate the hookshot deploy token (LXC 151):** 1. Generate a new token via Synapse admin API or Cinny → Settings → Security → Manage Sessions 2. SSH to LXC 151 (via `ssh root@10.10.10.4` then `pct enter 151`): `nano /etc/matrix-deploy.env` 3. Replace `MATRIX_TOKEN=` with new token 4. Test: `MATRIX_TOKEN= MATRIX_SERVER=https://matrix.lotusguild.org bash /opt/matrix-config/hookshot/deploy.sh` **To rotate the Draupnir token:** 1. Generate new token for `@draupnir:matrix.lotusguild.org` 2. On LXC 110: `nano /opt/draupnir/config/production.yaml` → update `accessToken` 3. `systemctl restart draupnir` 4. Do **not** commit the token to git — the repo version stays redacted --- ## Port Maps **Router → 10.10.10.29 (forwarded):** - TCP+UDP 3478 — TURN/STUN - TCP+UDP 5349 — TURNS/TLS - TCP 7881 — LiveKit ICE TCP fallback - TCP+UDP 49152-65535 — TURN relay range **Internal port map (LXC 151):** | Port | Service | Bind | |------|---------|------| | 8008 | Synapse HTTP | 0.0.0.0 | | 9000 | Synapse metrics | 127.0.0.1 + 10.10.10.29 | | 9001 | Hookshot widgets | 0.0.0.0 | | 9002 | Hookshot bridge (appservice) | 127.0.0.1 | | 9003 | Hookshot webhooks | 0.0.0.0 | | 9004 | Hookshot metrics | 0.0.0.0 | | 9100 | node_exporter | 0.0.0.0 | | 9101 | matrix-admin exporter | 0.0.0.0 | | 9500 | webhook (auto-deploy) | 0.0.0.0 | | 6789 | LiveKit metrics | 0.0.0.0 | | 7880 | LiveKit HTTP | 0.0.0.0 | | 7881 | LiveKit RTC TCP | 0.0.0.0 | | 8070 | voice-limit-guard (fronts lk-jwt-service) | 0.0.0.0 | | 8071 | lk-jwt-service (behind guard) | 0.0.0.0 | | 8080 | synapse-admin (nginx) | 0.0.0.0 | | 3478 | coturn STUN/TURN | 0.0.0.0 | | 5349 | coturn TURNS/TLS | 0.0.0.0 | **Internal port map (LXC 110 — Draupnir):** | Port | Service | Bind | |------|---------|------| | 8080 | Draupnir web (abuse reporting) | 0.0.0.0 | | 8081 | Draupnir healthz | 0.0.0.0 | | 9000 | webhook (auto-deploy) | 0.0.0.0 | | 9100 | node_exporter | 0.0.0.0 | | 9256 | process_exporter | 0.0.0.0 | **Internal port map (LXC 109 — PostgreSQL):** | Port | Service | Bind | |------|---------|------| | 5432 | PostgreSQL | 0.0.0.0 (hba-restricted to 10.10.10.29) | | 9100 | node_exporter | 0.0.0.0 | | 9187 | postgres_exporter | 0.0.0.0 | --- ## Rooms (all v12) | Room | Room ID | Join Rule | |------|---------|-----------| | The Lotus Guild (Space) | `!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc` | public | | General | `!wfokQ1-pE896scu_AOcCBA2s3L4qFo-PTBAFTd0WMI0` | public | | Commands | `!ou56mVZQ8ZB7AhDYPmBV5_BR28WMZ4x5zwZkPCqjq1s` | restricted (Space members) | | Memes | `!GK6v5cLEEnowIooQJv5jECfISUjADjt8aKhWv9VbG5U` | restricted (Space members) | | Music | `!ktQu0gavhjpCMkgxk8SYdb6mnJRY-u7mY7_KfksV0SU` | restricted (Space members) | | Voice Room | `!ARbRFSPNp2U0MslWTBGoTT3gbmJJ25dPRL6enQntvPo` | restricted (Space members) | | Management | `!mEvR5fe3jMmzwd-FwNygD72OY_yu8H3UP_N-57oK7MI` | invite | | Cool Kids | `!R7DT3QZHG9P8QQvX6zsZYxjkKgmUucxDz_n31qNrC94` | invite | | Spam and Stuff | `!GttT4QYd1wlGlkHU3qTmq_P3gbyYKKeSSN6R7TPcJHg` | invite, **no E2EE** (hookshot) | **Power level roles (Cinny tags):** - 100: Owner (jared, draupnir, lotusbot) - 50: The Nerdy Council / Panel of Geeks (enhuynh, lonely) - 0: Member --- ## Webhook Integrations (matrix-hookshot 7.3.2) Generic webhooks bridged into **Spam and Stuff**. Each service gets its own virtual user (`@hookshot_`) with a unique avatar. Webhook URL format: `https://matrix.lotusguild.org/webhook/` | Service | Webhook UUID | Notes | |---------|-------------|-------| | Grafana | `df4a1302-2d62-4a01-b858-fb56f4d3781a` | Unified alerting contact point | | Proxmox | `9b3eafe5-7689-4011-addd-c466e524661d` | Notification system (8.1+), Discord embed format | | Sonarr | `aeffc311-0686-42cb-9eeb-6757140c072e` | All event types | | Radarr | `34913454-c1ac-4cda-82ea-924d4a9e60eb` | All event types | | Readarr | `e57ab4f3-56e6-4dc4-8b30-2f4fd4bbeb0b` | All event types | | Lidarr | `66ac6fdd-69f6-4f47-bb00-b7f6d84d7c1c` | All event types | | Uptime Kuma | `1a02e890-bb25-42f1-99fe-bba6a19f1811` | Status change notifications | | Seerr | `555185af-90a1-42ff-aed5-c344e11955cf` | Request/approval events | | Owncast (Livestream) | `9993e911-c68b-4271-a178-c2d65ca88499` | STREAM_STARTED / STREAM_STOPPED | | Bazarr | `470fb267-3436-4dd3-a70c-e6e8db1721be` | Subtitle events (Apprise JSON notifier) | | Tinker-Tickets | `6e306faf-8eea-4ba5-83ef-bf8f421f929e` | Custom transformation code | **Hookshot notes:** - Spam and Stuff is intentionally **unencrypted** — hookshot bridges cannot join E2EE rooms - JS transformation functions use hookshot v2 API: `result = { version: "v2", plain, html, msgtype }` - The `result` variable must be assigned without `var`/`let`/`const` (QuickJS IIFE sandbox) - NPM proxies `https://matrix.lotusguild.org/webhook/*` → `http://10.10.10.29:9003` - NPM proxies `/sfu/get` and `/get_token` → `http://10.10.10.29:8070` (lk-jwt-service). Both paths are in `/data/nginx/proxy_host/49.conf` on LXC 139 — **NPM will overwrite these if proxy host 49 is re-saved via the UI; re-add both location blocks after any NPM save** - Proxmox sends Discord embed format: `data.embeds[0].{title,description,fields}` — NOT flat fields - Transform functions are stored as Matrix room state (`uk.half-shot.matrix-hookshot.generic.hook`) and deployed via `hookshot/deploy.sh` **Deploying hookshot transforms manually:** ```bash # On LXC 151 or from any machine with access export MATRIX_TOKEN= export MATRIX_SERVER=https://matrix.lotusguild.org export MATRIX_ROOM='!GttT4QYd1wlGlkHU3qTmq_P3gbyYKKeSSN6R7TPcJHg' bash /opt/matrix-config/hookshot/deploy.sh # deploy all bash /opt/matrix-config/hookshot/deploy.sh proxmox.js # deploy one ``` --- ## Moderation (Draupnir v2.9.0) Draupnir runs on LXC 110, manages moderation across all protected rooms (including the Lotus Guild space) via `#management:matrix.lotusguild.org`. **Subscribed ban lists:** - `#community-moderation-effort-bl:neko.dev` — 12,599 banned users, 245 servers, 59 rooms - `#matrix-org-coc-bl:matrix.org` — 4,589 banned users, 220 servers, 2 rooms **Common commands (send in management room):** ``` !draupnir status — current status + protected rooms !draupnir ban @user:server * "reason" — ban from all protected rooms !draupnir redact @user:server — redact their recent messages !draupnir rooms add !roomid:server — add a room to protection !draupnir watch --no-confirm — subscribe to a ban list ``` ### Abuse Reporting When a Matrix client user clicks "Report" on a message, Synapse receives a `POST /_matrix/client/v3/rooms/{roomId}/report/{eventId}` request and stores the report internally. To forward these to the Draupnir management room, a Synapse Python module must be installed on LXC 151. **Draupnir web server** is enabled (port 8080). The endpoint is: ``` POST http://10.10.10.24:8080/_matrix/draupnir/1/report/{roomId}/{eventId} ``` **To complete Synapse integration (one-time, on LXC 151):** 1. Install the module: `pip install matrix-synapse-draupnir-abuse-reports` (or equivalent — check Draupnir releases) 2. Add to `/etc/matrix-synapse/homeserver.yaml`: ```yaml modules: - module: "draupnir.abuse_reports.AbuseReportEndpoint" config: draupnir_endpoint: "http://10.10.10.24:8080" ``` 3. `systemctl restart matrix-synapse` > Until the Synapse module is installed, abuse reports are stored in Synapse's DB but do NOT appear in the management room. The Draupnir web server is running and ready to receive forwarded reports. --- ## Lotus Cinny (chat.lotusguild.org) `chat.lotusguild.org` serves a custom Lotus Guild fork of the official `cinnyapp/cinny` main branch. The fork lives at `code.lotusguild.org/LotusGuild/cinny` and tracks upstream via a `git remote add upstream https://github.com/cinnyapp/cinny.git` workflow. **Upstream monitoring (daily at noon):** - `cinny-upstream-check.sh` hits the GitHub API and compares the latest `cinnyapp/cinny` main commit against the stored SHA in `/var/lib/cinny-monitor/last-upstream-commit` - If new commits exist, sends a Matrix message to Spam and Stuff with an `@jared:matrix.lotusguild.org` ping and a link to the commit - Does **not** auto-build — you review the diff and decide when to merge **Merge + build workflow:** 1. Receive upstream notification in Matrix 2. Review the diff: `https://github.com/cinnyapp/cinny/compare/...` 3. Send `!cinny-update` in any Matrix room — LotusBot POSTs to the cinny-build webhook on LXC 106 4. `cinny-build.sh` runs: `git fetch upstream && git merge upstream/main`, `npm ci`, `npm run build`, deploys to `/var/www/html/` 5. Build result (success or conflict) is posted back to Matrix **Manual build (SSH):** ```bash # On LXC 106 /usr/local/bin/cinny-build.sh ``` **Merge conflict recovery:** ```bash # On LXC 106 cd /opt/lotus-cinny git merge upstream/main # resolve conflicts in editor git add -A && git merge --continue /usr/local/bin/cinny-build.sh ``` **LXC 106 one-time setup** (after forking `cinnyapp/cinny` to `code.lotusguild.org/LotusGuild/cinny`): ```bash # On LXC 106 git clone https://code.lotusguild.org/LotusGuild/cinny.git /opt/lotus-cinny cd /opt/lotus-cinny git remote add upstream https://github.com/cinnyapp/cinny.git git fetch upstream # Create env file (fill in a valid Matrix token) cat > /etc/cinny-monitor.env << 'EOF' MATRIX_TOKEN= MATRIX_SERVER=https://matrix.lotusguild.org MATRIX_ROOM=!GttT4QYd1wlGlkHU3qTmq_P3gbyYKKeSSN6R7TPcJHg MATRIX_PING_USER=@jared:matrix.lotusguild.org EOF chmod 600 /etc/cinny-monitor.env ``` **Cinny-build webhook token** (for LotusBot `!cinny-update`): stored in `deploy/hooks-lxc106.json` (`cinny-build` hook, header `X-Build-Token`). LotusBot must POST to `http://10.10.10.6:9000/hooks/cinny-build` with this header. **Why 8GB RAM:** Vite's build process needs ~6GB Node heap (`--max_old_space_size=6144`) for the rendering-chunks phase. Previously at 4GB — OOM killed during render. ### 🔱 Element Call fork — "Lotus Call" (true ownership) — LIVE We **self-build** Element Call from a fork (`LotusGuild/element-call`) and publish it to our private Gitea npm registry as `@lotusguild/element-call-embedded` (`0.20.1-lotus.1`); cinny consumes that instead of the upstream `@element-hq/element-call-embedded` bundle. In-call behavior is now editable source, not just widget-API + DOM steering. This is AGPL (same license). **Shipped via the fork:** in-source denoise (a LiveKit `TrackProcessor` that survives reconnects), in-call speaking/mute events, focus-a-participant during screenshare, avatar decorations on EC video tiles, native transparent background. **Built but dormant (need cinny UI):** call-audio injection (`io.lotus.inject_audio`, unblocks a real in-call soundboard) and quality controls (`io.lotus.set_quality`). Infra notes for THIS repo: - EC talks to our **LiveKit SFU** (`livekit/`, LXC 151) + `lk-jwt-service`; the fork's runtime `config.json` points at `matrix.lotusguild.org` + our LiveKit. The cinny EC `config.json` lives in `cinny/config.json` here. - **Build/deploy:** the fork builds in the cinny pipeline (its `dist/` is bundled into the cinny build that LXC 106 serves) — no separate EC LXC. A future quality controls feature (P5-31) would add a `voice-limit-guard`-style sidecar on LXC 151. **Full handoff & step-by-step plan:** `LotusGuild/cinny` → [`HANDOFF_ELEMENT_CALL_FORK.md`](https://code.lotusguild.org/LotusGuild/cinny/src/branch/lotus/HANDOFF_ELEMENT_CALL_FORK.md). ### Custom Features All custom code lives in `src/app/` on the `lotus` branch of `code.lotusguild.org/LotusGuild/cinny`. Changes survive upstream merges as long as they don't conflict with the same files upstream touched. | Feature | Files | Notes | |---------|-------|-------| | **Element Call embed** | `src/app/plugins/call/`, `src/app/hooks/useCallEmbed.ts`, `src/app/components/CallEmbedProvider.tsx` | 🔱 **[EC-FORK] LIVE** — self-built fork `@lotusguild/element-call-embedded@0.20.1-lotus.1` (source `LotusGuild/element-call`), bundled into the cinny build, served same-origin. Steered via `matrix-widget-api` + custom `io.lotus.*` actions (call_state, focus_participant, decorations, inject_audio, set_quality) — DOM-poking retained only as fallback. See `LotusGuild/cinny` → `HANDOFF_ELEMENT_CALL_FORK.md` | | **DM calls** | `src/app/features/room/Room.tsx`, `src/app/features/room/RoomViewHeader.tsx` | Phone button in DM room header; `useCallStart(true)` passes `intent: StartedByUser`; Room.tsx switches to CallView layout when DM has active call | | **Picture-in-picture call** | `src/app/components/CallEmbedProvider.tsx` | When navigating away from the call room, the embed shrinks to a 280×158px PiP in the bottom-right. Click navigates back. Implemented via `useEffect` imperatively overriding styles on `callEmbedRef.current` — cannot use a wrapper div because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element | | **Screenshare fullscreen** | `src/app/features/call/CallControls.tsx`, `src/app/features/call/Controls.tsx` | When screensharing, a fullscreen button appears in call controls. Calls `callEmbedRef.current?.requestFullscreen()` on the Cinny call container. EC naturally spotlights the screenshare — the old 600ms grid-revert code was removed (it caused fullscreen to show avatars instead of the screen) | | **PiP screenshare focus** | `src/app/components/CallEmbedProvider.tsx`, `src/app/plugins/call/CallControl.ts` | When the floating PiP window is active and screenshare is detected (no cameras present), auto-enables EC spotlight view so the screenshare fills the PiP rather than showing avatar tiles | | **Screenshare audio mute** | `src/app/features/call/Controls.tsx`, `src/app/features/call/CallControls.tsx`, `src/app/plugins/call/CallControl.ts` | Dedicated button to independently mute/unmute audio from screenshares without muting microphone audio. Targets `audio[data-lk-source="screen_share_audio"]` LiveKit elements. Persists across deafen/undeafen cycles | | **In-call soundboard (P5-15)** | `src/app/features/call/CallSoundboard.tsx`, `src/app/hooks/useSoundboard.ts`, `src/app/utils/soundboardClips.ts`, `CallControl.ts#injectAudio` | Call-bar popout of user-uploaded clips. Playing one sends the fork's `io.lotus.inject_audio` (armed via `lotusAudioInject=1`) so it publishes as a real LiveKit track heard by all, plus local playback. Clips are uploadable like emoji/sticker packs — stored in `io.lotus.soundboard` account data (synced across devices); host resolves mxc → authed download → `blob:` URL for the widget. Gated by `soundboardEnabled` setting | | **Call quality controls (P5-31)** | `src/app/utils/callQuality.ts`, `src/app/hooks/useCallQuality.ts`, `CallControl.ts#setQuality` | Per-user mic/screenshare bitrate + screenshare framerate (Settings → Calls), applied via the fork's `io.lotus.set_quality`, clamped to any room cap (`min(user, room)`). **Client-cooperative** (numeric caps aren't SFU-enforceable). Unit-tested | | **Room call permissions (P5-31)** | `src/app/features/common-settings/general/RoomQuality.tsx`, `types/matrix/room.ts` (`LotusRoomQuality`), `CallControls.tsx` | Admin switches in Room Settings → Voice write `io.lotus.room_quality` `allow_screenshare`/`allow_camera`; the call bar hides blocked buttons. **Hard-enforced server-side for all clients** by `voice-limit-guard` (this repo) — see [Voice Channel Limits & Call Permissions](#voice-channel-limits--call-permissions) | | **Custom status message** | `src/app/features/settings/account/Profile.tsx`, `src/app/features/room/MembersDrawer.tsx`, `src/app/components/user-profile/UserHero.tsx`, `src/app/components/user-profile/UserRoomProfile.tsx`, `src/app/hooks/useUserPresence.ts` | Discord-style free-form status text. Set via Settings → Account → "Status Message" with an emoji picker (lazy-loaded `EmojiBoard`). Saved via `mx.setPresence({ status_msg })`. Displayed below the username in the members drawer and user profile popout. Syncs live via Matrix presence events | | **PTT (Push-to-Talk)** | `src/app/features/call/CallControls.tsx`, `src/app/state/settings.ts` | Hold-to-talk key (default: Space, configurable). Mutes mic on join; holds mic open while key is held. Badge shows `PTT — Hold SPACE` / `● Live`. Listens on both main window and EC iframe `contentWindow` for key events | | **PTT badge theming** | `src/app/features/call/CallControls.tsx` | Plain folds `Chip` by default; neon terminal style (`#00FF88`/`#FF6B00`, JetBrains Mono) when `lotusTerminal` setting is on | | **GIF picker** | `src/app/components/GifPicker.tsx`, `src/app/features/room/RoomInput.tsx` | Giphy JS/React SDK (`@giphy/react-components`, `@giphy/js-fetch-api`, `styled-components`). API key in `config.json` → `gifApiKey`. GIF button appears next to Send only when `gifApiKey` is set. Sends GIF as `m.image` (fetches blob → `mx.uploadContent` → `mx.sendMessage`). `FocusTrap` handles click-outside / Escape to close | | **GIF picker terminal theme** | `src/app/components/GifPicker.tsx` | When `lotusTerminal` is on: dark navy background (`#060c14`), orange dim border, 4px radius, `// GIF_SEARCH` header, injected `