diff --git a/CLAUDE.md b/CLAUDE.md index f9e21d8..c0b7162 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -464,6 +464,7 @@ Add-on skills (activated when corresponding add-on is installed): - `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar - `gotosocial.md` — GoToSocial ActivityPub microblog: post, follow, search, moderate (block_user/mute inline; defederate/block_domain/import_blocklist queued for operator confirmation), media prune, federation health - `writefreely.md` — WriteFreely federated blog: create/update/publish/unpublish posts, list collections, fetch public posts, export; minimalist publisher (no comments, no moderation queue — WF is publish-oriented only) +- `matrix-dendrite.md` — Matrix homeserver on Dendrite: create/join/leave rooms, send messages, sync, invite users, federation health; appservice registration prep for F.12 bridges; :8448-vs-well-known either/or federation story - `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS - `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download - `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read diff --git a/bundles/matrix-dendrite/.env.example b/bundles/matrix-dendrite/.env.example new file mode 100644 index 0000000..8836055 --- /dev/null +++ b/bundles/matrix-dendrite/.env.example @@ -0,0 +1,32 @@ +# Matrix-Dendrite — required config + +# Public server_name — the part that appears in Matrix user IDs +# (@alice:example.com). If you use .well-known delegation, set this to +# your apex (example.com). If you run dendrite directly at a subdomain, +# set to the subdomain (matrix.example.com). +MATRIX_SERVER_NAME=example.com + +# Domain Caddy reverse-proxies the client-server API on. Usually a +# subdomain (matrix.example.com) even when MATRIX_SERVER_NAME is the +# apex — .well-known on the apex delegates to this. +MATRIX_HOST=matrix.example.com + +# Postgres password for the bundled database. Generate with: +# openssl rand -base64 32 | tr -d '/+=' | head -c 40 +MATRIX_POSTGRES_PASSWORD= + +# Internal URL the Crow MCP server uses to reach Dendrite over the +# crow-federation docker network. +MATRIX_URL=http://dendrite:8008 + +# Filled AFTER the admin account is registered (see scripts/post-install.sh +# output). Obtain via POST /_matrix/client/v3/login. +MATRIX_ACCESS_TOKEN= +MATRIX_USER_ID=@admin:example.com + +# Populated on first boot by the dendrite entrypoint (printed to logs). +# Copy the "Registration shared secret: XXX" line here. +MATRIX_REGISTRATION_SHARED_SECRET= + +# Host data directory override. +# MATRIX_DATA_DIR=/mnt/nvme/matrix-dendrite diff --git a/bundles/matrix-dendrite/docker-compose.yml b/bundles/matrix-dendrite/docker-compose.yml new file mode 100644 index 0000000..86170e1 --- /dev/null +++ b/bundles/matrix-dendrite/docker-compose.yml @@ -0,0 +1,99 @@ +# Matrix-Dendrite — federated real-time chat. +# +# Two-container bundle: dendrite + postgres. Both on crow-federation; no +# host port publish. Caddy reverse-proxies :443 → dendrite:8008 for the +# client-server API. Federation (:8448) is handled via either: +# (a) caddy_add_matrix_federation_port forwarding :8448 → dendrite:8448 +# (router must forward 8448/tcp), OR +# (b) caddy_set_wellknown matrix-server on the apex domain +# +# Data: +# ~/.crow/matrix-dendrite/dendrite/ signing keys, media, jetstream +# ~/.crow/matrix-dendrite/postgres/ Postgres data dir +# First-boot entrypoint generates signing keys + dendrite.yaml if absent. + +networks: + crow-federation: + external: true + default: + +services: + postgres: + image: postgres:16-alpine + container_name: crow-dendrite-postgres + networks: + - default + environment: + POSTGRES_USER: dendrite + POSTGRES_PASSWORD: ${MATRIX_POSTGRES_PASSWORD} + POSTGRES_DB: dendrite + # Single-database install: Dendrite accepts a single DB URI and + # manages its own schema namespaces internally. + volumes: + - ${MATRIX_DATA_DIR:-~/.crow/matrix-dendrite}/postgres:/var/lib/postgresql/data + init: true + mem_limit: 1g + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dendrite"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + dendrite: + image: matrixdotorg/dendrite-monolith:v0.13.8 + container_name: crow-dendrite + networks: + - default + - crow-federation + depends_on: + postgres: + condition: service_healthy + environment: + MATRIX_SERVER_NAME: ${MATRIX_SERVER_NAME} + MATRIX_POSTGRES_URL: "postgres://dendrite:${MATRIX_POSTGRES_PASSWORD}@postgres:5432/dendrite?sslmode=disable" + DENDRITE_TRACE_HTTP: "0" + volumes: + - ${MATRIX_DATA_DIR:-~/.crow/matrix-dendrite}/dendrite:/etc/dendrite + entrypoint: + - sh + - -c + - | + set -e + CFG=/etc/dendrite/dendrite.yaml + KEY=/etc/dendrite/matrix_key.pem + if [ ! -f "$$CFG" ]; then + # First boot — generate signing key + config + /usr/bin/generate-keys --private-key "$$KEY" + # Generate minimal config via the bundled template approach. + # Dendrite's "generate-config" tool wants to print to stdout. + /usr/bin/generate-config \ + --server "$${MATRIX_SERVER_NAME}" \ + --db "$${MATRIX_POSTGRES_URL}" \ + --private-key "$$KEY" \ + > "$$CFG" + # Enable registration with shared secret for the host-side + # create-account helper. Patch the generated yaml in place: + SECRET=$$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 48) + # shellcheck disable=SC2016 + sed -i "s|^\\s*registration_shared_secret:.*| registration_shared_secret: \"$$SECRET\"|" "$$CFG" + # Ensure federation + metrics are on + sed -i "s|^\\s*disable_federation:.*| disable_federation: false|" "$$CFG" + echo "Generated /etc/dendrite/dendrite.yaml with fresh signing key." + echo "Registration shared secret: $$SECRET" + echo "(copy this into MATRIX_REGISTRATION_SHARED_SECRET in .env)" + fi + exec /usr/bin/dendrite \ + --config /etc/dendrite/dendrite.yaml \ + --http-bind-address 0.0.0.0:8008 \ + --https-bind-address 0.0.0.0:8448 + init: true + mem_limit: 4g + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8008/_matrix/client/versions >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 60s diff --git a/bundles/matrix-dendrite/manifest.json b/bundles/matrix-dendrite/manifest.json new file mode 100644 index 0000000..7c26782 --- /dev/null +++ b/bundles/matrix-dendrite/manifest.json @@ -0,0 +1,77 @@ +{ + "id": "matrix-dendrite", + "name": "Matrix (Dendrite)", + "version": "1.0.0", + "description": "Matrix homeserver on Dendrite — federated real-time chat with end-to-end encryption. Lighter than Synapse, monolithic binary. Joins the Matrix federation graph via HTTPS federation (:8448 or .well-known delegation).", + "type": "bundle", + "author": "Crow", + "category": "federated-comms", + "tags": ["matrix", "dendrite", "chat", "federation", "e2ee", "realtime"], + "icon": "message-circle", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["MATRIX_URL", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_SERVER_NAME"] + }, + "panel": "panel/matrix-dendrite.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/matrix-dendrite.md"], + "consent_required": true, + "install_consent_messages": { + "en": "Dendrite joins the public Matrix federation graph. Your homeserver becomes addressable at the domain you configure; any room you join, create, or publish is replicated (including message history) to every other server with a member in that room — and that replication cannot be fully recalled. Federation requires EITHER port 8448/tcp forwarded from your router to this host (Caddy will request a dedicated cert via caddy_add_matrix_federation_port), OR a .well-known/matrix/server JSON delegation on your apex domain (caddy_set_wellknown). Neither works without explicit setup. Dendrite stores all room state + message history + media in Postgres; federated joins of large rooms (e.g. Matrix HQ) can add tens of GB within days. Automatic media retention trimming is not yet wired up in this bundle — monitor disk. If your homeserver is reported for abuse, major homeservers (matrix.org, synapse.matrix.org) may defederate your domain; a poisoned domain cannot easily be rehabilitated. Dendrite itself is hardware-gated to refuse install on hosts with <8 GB RAM — the 2 GB minimum is only realistic when the homeserver isn't federated with busy rooms.", + "es": "Dendrite se une al grafo público de federación de Matrix. Tu homeserver será direccionable en el dominio que configures; cualquier sala a la que te unas, crees o publiques se replica (incluido el historial de mensajes) a todos los servidores que tengan un miembro en esa sala — y esa replicación no puede recuperarse completamente. La federación requiere O el puerto 8448/tcp reenviado desde tu router a este host (Caddy solicitará un certificado dedicado vía caddy_add_matrix_federation_port) O una delegación JSON en .well-known/matrix/server en tu dominio raíz (caddy_set_wellknown). Ninguno funciona sin configuración explícita. Dendrite almacena todo el estado de sala + historial + medios en Postgres; las uniones federadas a salas grandes (p. ej. Matrix HQ) pueden añadir decenas de GB en días. El recorte automático de retención de medios aún no está cableado en este paquete — monitoriza el disco. Si tu homeserver es reportado por abuso, los homeservers principales (matrix.org, synapse.matrix.org) pueden dejar de federarse con tu dominio; un dominio envenenado no puede rehabilitarse fácilmente. Dendrite se niega a instalarse en hosts con <8 GB RAM — el mínimo de 2 GB solo es realista cuando el homeserver no está federado con salas muy activas." + }, + "requires": { + "env": ["MATRIX_SERVER_NAME", "MATRIX_POSTGRES_PASSWORD"], + "bundles": ["caddy"], + "min_ram_mb": 2048, + "recommended_ram_mb": 4096, + "min_disk_mb": 10000, + "recommended_disk_mb": 100000 + }, + "env_vars": [ + { + "name": "MATRIX_SERVER_NAME", + "description": "The homeserver's public domain — this is the server_name that appears in user IDs (@alice:example.com). Typically an apex (example.com) with a .well-known/matrix/server delegation, OR a subdomain you'll expose on :8448 directly (matrix.example.com).", + "required": true + }, + { + "name": "MATRIX_HOST", + "description": "Domain Caddy reverse-proxies the client-server API from. If using .well-known delegation, this differs from MATRIX_SERVER_NAME (e.g., server_name=example.com, host=matrix.example.com). If using :8448, usually the same as server_name.", + "required": false + }, + { + "name": "MATRIX_POSTGRES_PASSWORD", + "description": "Password for the dendrite Postgres role (the bundle provisions Postgres inside the compose).", + "required": true, + "secret": true + }, + { + "name": "MATRIX_URL", + "description": "Internal URL the Crow MCP server uses to reach Dendrite's client-server API (over the crow-federation docker network).", + "default": "http://dendrite:8008", + "required": false + }, + { + "name": "MATRIX_ACCESS_TOKEN", + "description": "Access token for the admin account (obtain via POST /_matrix/client/v3/login after registering the admin via the Dendrite CLI).", + "required": false, + "secret": true + }, + { + "name": "MATRIX_USER_ID", + "description": "Admin user's full Matrix ID (@admin:example.com). Used by the MCP server to scope tools to the admin context.", + "required": false + }, + { + "name": "MATRIX_REGISTRATION_SHARED_SECRET", + "description": "Dendrite's registration shared secret (generated on first boot; used by the register-admin.sh script). Do not expose.", + "required": false, + "secret": true + } + ], + "ports": [], + "webUI": null, + "notes": "Two containers: dendrite + postgres. Both on crow-federation network; no host port publish. Federation needs EITHER caddy_add_matrix_federation_port (forwards 8448/tcp from router) OR caddy_set_wellknown matrix-server on the apex. Initial admin must be registered via `docker exec crow-dendrite create-account` after first boot." +} diff --git a/bundles/matrix-dendrite/package.json b/bundles/matrix-dendrite/package.json new file mode 100644 index 0000000..49fb6ed --- /dev/null +++ b/bundles/matrix-dendrite/package.json @@ -0,0 +1,11 @@ +{ + "name": "crow-matrix-dendrite", + "version": "1.0.0", + "description": "Dendrite (Matrix homeserver) MCP server — rooms, messages, sync, invites, federation health", + "type": "module", + "main": "server/index.js", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "zod": "^3.24.0" + } +} diff --git a/bundles/matrix-dendrite/panel/matrix-dendrite.js b/bundles/matrix-dendrite/panel/matrix-dendrite.js new file mode 100644 index 0000000..b10238e --- /dev/null +++ b/bundles/matrix-dendrite/panel/matrix-dendrite.js @@ -0,0 +1,182 @@ +/** + * Crow's Nest Panel — Matrix-Dendrite: status + rooms + federation health. + * XSS-safe (textContent + createElement only). + */ + +export default { + id: "matrix-dendrite", + name: "Matrix (Dendrite)", + icon: "message-circle", + route: "/dashboard/matrix-dendrite", + navOrder: 72, + category: "federated-comms", + + async handler(req, res, { layout }) { + const content = ` + +
+

Matrix Dendrite homeserver

+ +
+

Status

+
Loading…
+
+ +
+

Federation Health

+
Checking…
+
+ +
+

Joined Rooms

+
Loading…
+
+ +
+

Notes

+ +
+
+ + `; + res.send(layout({ title: "Matrix", content })); + }, +}; + +function script() { + return ` + function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); } + function row(label, value) { + const r = document.createElement('div'); r.className = 'mx-row'; + const b = document.createElement('b'); b.textContent = label; + const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value); + r.appendChild(b); r.appendChild(s); return r; + } + function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; } + + async function loadStatus() { + const el = document.getElementById('mx-status'); clear(el); + try { + const res = await fetch('/api/matrix-dendrite/status'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + const card = document.createElement('div'); card.className = 'mx-card'; + card.appendChild(row('Server name', d.server_name || '(unset)')); + card.appendChild(row('Internal URL', d.url)); + card.appendChild(row('Client-server API versions', (d.versions || []).join(', ') || '—')); + card.appendChild(row('Authenticated', d.whoami?.user_id || (d.has_token ? '(token set but whoami failed)' : '(no token)'))); + el.appendChild(card); + } catch (e) { el.appendChild(err('Cannot reach Dendrite.')); } + } + + async function loadFederation() { + const el = document.getElementById('mx-fed'); clear(el); + try { + const res = await fetch('/api/matrix-dendrite/federation-health'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + const card = document.createElement('div'); card.className = 'mx-card'; + const badge = document.createElement('span'); + badge.className = 'mx-fed-badge ' + (d.federation_ok ? 'mx-fed-ok' : 'mx-fed-bad'); + badge.textContent = d.federation_ok ? 'FEDERATION OK' : 'FEDERATION ISSUES'; + card.appendChild(badge); + card.appendChild(row('Server name', d.server_name)); + card.appendChild(row('.well-known m.server', d.well_known || '(none — using :8448 direct)')); + if (d.errors && d.errors.length) { + const eh = document.createElement('div'); eh.className = 'mx-errors-head'; eh.textContent = 'Errors:'; + card.appendChild(eh); + for (const er of d.errors) { + const li = document.createElement('div'); li.className = 'mx-err-item'; + li.textContent = typeof er === 'string' ? er : JSON.stringify(er); + card.appendChild(li); + } + } + if (d.warnings && d.warnings.length) { + const wh = document.createElement('div'); wh.className = 'mx-warn-head'; wh.textContent = 'Warnings:'; + card.appendChild(wh); + for (const w of d.warnings) { + const li = document.createElement('div'); li.className = 'mx-warn-item'; + li.textContent = typeof w === 'string' ? w : JSON.stringify(w); + card.appendChild(li); + } + } + el.appendChild(card); + } catch (e) { el.appendChild(err('Federation tester failed: ' + e.message)); } + } + + async function loadRooms() { + const el = document.getElementById('mx-rooms'); clear(el); + try { + const res = await fetch('/api/matrix-dendrite/rooms'); const d = await res.json(); + if (d.error) { el.appendChild(err(d.error)); return; } + if (!d.rooms || d.rooms.length === 0) { + const i = document.createElement('div'); i.className = 'np-idle'; + i.textContent = 'No joined rooms yet. Try matrix_join_room { room: "#matrix:matrix.org" }.'; + el.appendChild(i); return; + } + for (const r of d.rooms) { + const c = document.createElement('div'); c.className = 'mx-room'; + const h = document.createElement('div'); h.className = 'mx-room-head'; + const t = document.createElement('b'); t.textContent = r.name || r.alias || '(unnamed)'; + h.appendChild(t); + if (r.alias && r.alias !== (r.name || '')) { + const a = document.createElement('span'); a.className = 'mx-room-alias'; a.textContent = r.alias; + h.appendChild(a); + } + c.appendChild(h); + const id = document.createElement('div'); id.className = 'mx-room-id'; id.textContent = r.room_id; + c.appendChild(id); + el.appendChild(c); + } + if (d.count > d.rooms.length) { + const more = document.createElement('div'); more.className = 'mx-more'; + more.textContent = 'Showing ' + d.rooms.length + ' of ' + d.count + ' rooms.'; + el.appendChild(more); + } + } catch (e) { el.appendChild(err('Cannot load rooms: ' + e.message)); } + } + + loadStatus(); + loadFederation(); + loadRooms(); + `; +} + +function styles() { + return ` + .mx-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; } + .mx-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; } + .mx-section { margin-bottom: 1.8rem; } + .mx-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase; + letter-spacing: 0.05em; margin: 0 0 0.7rem; } + .mx-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 10px; padding: 1rem; } + .mx-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); } + .mx-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; } + .mx-fed-badge { display: inline-block; font-size: .8rem; font-weight: 600; padding: .3rem .6rem; + border-radius: 6px; letter-spacing: .05em; margin-bottom: .7rem; } + .mx-fed-ok { background: rgba(34,197,94,.15); color: #22c55e; } + .mx-fed-bad { background: rgba(239,68,68,.15); color: #ef4444; } + .mx-errors-head, .mx-warn-head { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; + margin: .6rem 0 .2rem; color: var(--crow-text-muted); } + .mx-err-item { font-size: .8rem; color: #ef4444; font-family: ui-monospace, monospace; padding: .15rem 0; } + .mx-warn-item { font-size: .8rem; color: #eab308; font-family: ui-monospace, monospace; padding: .15rem 0; } + .mx-room { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border); + border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; } + .mx-room-head { display: flex; gap: .5rem; align-items: baseline; } + .mx-room-head b { color: var(--crow-text-primary); font-size: .9rem; } + .mx-room-alias { font-size: .8rem; color: var(--crow-accent); font-family: ui-monospace, monospace; } + .mx-room-id { font-size: .7rem; color: var(--crow-text-muted); font-family: ui-monospace, monospace; margin-top: .2rem; } + .mx-more { font-size: .8rem; color: var(--crow-text-muted); text-align: center; padding: .4rem 0; } + .mx-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; } + .mx-notes li { margin-bottom: .3rem; } + .mx-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg); + padding: 1px 4px; border-radius: 3px; font-size: .8em; } + .np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + .np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem; + background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + `; +} diff --git a/bundles/matrix-dendrite/panel/routes.js b/bundles/matrix-dendrite/panel/routes.js new file mode 100644 index 0000000..40b8308 --- /dev/null +++ b/bundles/matrix-dendrite/panel/routes.js @@ -0,0 +1,87 @@ +/** + * Matrix-Dendrite panel API routes — status, joined rooms, federation health. + */ + +import { Router } from "express"; + +const URL_BASE = () => (process.env.MATRIX_URL || "http://dendrite:8008").replace(/\/+$/, ""); +const TOKEN = () => process.env.MATRIX_ACCESS_TOKEN || ""; +const SERVER_NAME = () => process.env.MATRIX_SERVER_NAME || ""; +const TIMEOUT = 15_000; + +async function mx(path, { noAuth } = {}) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), TIMEOUT); + try { + const headers = {}; + if (!noAuth && TOKEN()) headers.Authorization = `Bearer ${TOKEN()}`; + const r = await fetch(`${URL_BASE()}${path}`, { signal: ctl.signal, headers }); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + const text = await r.text(); + return text ? JSON.parse(text) : {}; + } finally { + clearTimeout(t); + } +} + +export default function matrixDendriteRouter(authMiddleware) { + const router = Router(); + + router.get("/api/matrix-dendrite/status", authMiddleware, async (_req, res) => { + try { + const versions = await mx("/_matrix/client/versions", { noAuth: true }).catch(() => null); + const who = TOKEN() ? await mx("/_matrix/client/v3/account/whoami").catch(() => null) : null; + res.json({ + server_name: SERVER_NAME(), + url: URL_BASE(), + versions: versions?.versions?.slice(-3) || null, + has_token: Boolean(TOKEN()), + whoami: who, + }); + } catch (err) { + res.json({ error: `Cannot reach Dendrite: ${err.message}` }); + } + }); + + router.get("/api/matrix-dendrite/rooms", authMiddleware, async (_req, res) => { + try { + if (!TOKEN()) return res.json({ error: "MATRIX_ACCESS_TOKEN not set" }); + const { joined_rooms = [] } = await mx("/_matrix/client/v3/joined_rooms"); + const withNames = []; + for (const rid of joined_rooms.slice(0, 50)) { + const name = await mx(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.name`).catch(() => null); + const alias = await mx(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.canonical_alias`).catch(() => null); + withNames.push({ room_id: rid, name: name?.name || null, alias: alias?.alias || null }); + } + res.json({ count: joined_rooms.length, rooms: withNames }); + } catch (err) { + res.json({ error: err.message }); + } + }); + + router.get("/api/matrix-dendrite/federation-health", authMiddleware, async (_req, res) => { + try { + const target = SERVER_NAME(); + if (!target) return res.json({ error: "MATRIX_SERVER_NAME not set" }); + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 30_000); + try { + const r = await fetch(`https://federationtester.matrix.org/api/report?server_name=${encodeURIComponent(target)}`, { signal: ctl.signal }); + const json = await r.json(); + res.json({ + server_name: target, + federation_ok: json.FederationOK, + well_known: json.WellKnownResult?.["m.server"] || null, + errors: (json.Errors || []).slice(0, 10), + warnings: (json.Warnings || []).slice(0, 10), + }); + } finally { + clearTimeout(t); + } + } catch (err) { + res.json({ error: err.message }); + } + }); + + return router; +} diff --git a/bundles/matrix-dendrite/scripts/backup.sh b/bundles/matrix-dendrite/scripts/backup.sh new file mode 100755 index 0000000..6a1146f --- /dev/null +++ b/bundles/matrix-dendrite/scripts/backup.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Matrix-Dendrite backup: pg_dump + signing key + media store + config. +set -euo pipefail + +STAMP="$(date -u +%Y%m%dT%H%M%SZ)" +BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/matrix-dendrite" +DATA_DIR="${MATRIX_DATA_DIR:-$HOME/.crow/matrix-dendrite}" + +mkdir -p "$BACKUP_ROOT" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# Postgres dump — use pg_dumpall to capture roles + schemas in one file +if docker ps --format '{{.Names}}' | grep -qw crow-dendrite-postgres; then + docker exec -e PGPASSWORD="${MATRIX_POSTGRES_PASSWORD:-}" crow-dendrite-postgres \ + pg_dump -U dendrite -Fc -f /tmp/dendrite-${STAMP}.pgcustom dendrite + docker cp "crow-dendrite-postgres:/tmp/dendrite-${STAMP}.pgcustom" "$WORK/dendrite.pgcustom" + docker exec crow-dendrite-postgres rm "/tmp/dendrite-${STAMP}.pgcustom" +fi + +# Dendrite signing keys + config + media store +tar -C "$DATA_DIR/dendrite" -cf "$WORK/dendrite-state.tar" . 2>/dev/null || true + +OUT="${BACKUP_ROOT}/matrix-dendrite-${STAMP}.tar.zst" +if command -v zstd >/dev/null 2>&1; then + tar -C "$WORK" -cf - . | zstd -T0 -19 -o "$OUT" +else + OUT="${BACKUP_ROOT}/matrix-dendrite-${STAMP}.tar.gz" + tar -C "$WORK" -czf "$OUT" . +fi +echo "wrote $OUT ($(du -h "$OUT" | cut -f1))" +echo "NOTE: the signing key in dendrite-state.tar IS federation identity." +echo " Restoring to a different MATRIX_SERVER_NAME will break federation." +echo " Keep this backup encrypted — loss = identity loss, leak = impersonation." diff --git a/bundles/matrix-dendrite/scripts/post-install.sh b/bundles/matrix-dendrite/scripts/post-install.sh new file mode 100755 index 0000000..060992a --- /dev/null +++ b/bundles/matrix-dendrite/scripts/post-install.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Matrix-Dendrite post-install hook. +# +# The entrypoint does the heavy lifting (signing key + config generation), +# but first-boot timing means we need a health wait before printing the +# registration shared secret + next-step guidance. +set -euo pipefail + +BUNDLE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${BUNDLE_DIR}/.env" +if [ -f "$ENV_FILE" ]; then + set -a; . "$ENV_FILE"; set +a +fi + +# Wait for Dendrite to become healthy (first boot can take 60+ seconds) +echo "Waiting for Dendrite to report healthy (up to 120s)…" +for i in $(seq 1 24); do + if docker inspect crow-dendrite --format '{{.State.Health.Status}}' 2>/dev/null | grep -qw healthy; then + echo " → healthy" + break + fi + sleep 5 +done + +# Extract the generated registration shared secret from logs (entrypoint +# prints it on first boot) +SECRET=$(docker logs crow-dendrite 2>&1 | grep -oE 'Registration shared secret: [A-Za-z0-9+/=]+' | tail -1 | cut -d: -f2 | xargs || true) + +cat <' --admin + +EOF + +if [ -n "${SECRET:-}" ]; then + echo " Registration shared secret (copy to .env as MATRIX_REGISTRATION_SHARED_SECRET):" + echo " ${SECRET}" + echo "" +fi + +cat <"}' + Paste access_token and user_id into .env as MATRIX_ACCESS_TOKEN and + MATRIX_USER_ID, then restart the MCP server. + + 4. Verify federation end-to-end: + matrix_federation_health { server_name: "${MATRIX_SERVER_NAME:-example.com}" } + +EOF diff --git a/bundles/matrix-dendrite/server/index.js b/bundles/matrix-dendrite/server/index.js new file mode 100644 index 0000000..bd85c99 --- /dev/null +++ b/bundles/matrix-dendrite/server/index.js @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { createMatrixDendriteServer } from "./server.js"; + +const server = await createMatrixDendriteServer(); +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/bundles/matrix-dendrite/server/server.js b/bundles/matrix-dendrite/server/server.js new file mode 100644 index 0000000..b2cd823 --- /dev/null +++ b/bundles/matrix-dendrite/server/server.js @@ -0,0 +1,500 @@ +/** + * Matrix (Dendrite) MCP Server + * + * Exposes Matrix's client-server API through MCP. Dendrite speaks the + * standard Matrix v3 API so these tools also work against Synapse or + * other homeservers if someone points MATRIX_URL elsewhere. + * + * Tools: + * matrix_status — reachability, version, federation mode + * matrix_joined_rooms — list rooms the admin account is in + * matrix_create_room — create a room (public / private / DM) + * matrix_join_room — join by ID or alias (triggers federation) + * matrix_leave_room — leave a room + * matrix_send_message — send a message event to a room + * matrix_room_messages — paginated message history + * matrix_sync — one-shot sync (not long-poll; panel uses + * a different long-poll endpoint) + * matrix_invite_user — invite a user to a room + * matrix_register_appservice — write an appservice YAML registration + * (used by F.12.1 matrix-bridges bundle) + * matrix_federation_health — check outgoing federation with the + * Matrix Federation Tester + * + * Rate limiting follows the F.0 shared pattern: content-producing verbs + * (create_room, send_message, join_room, invite_user) are wrapped. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const MATRIX_URL = (process.env.MATRIX_URL || "http://dendrite:8008").replace(/\/+$/, ""); +const MATRIX_ACCESS_TOKEN = process.env.MATRIX_ACCESS_TOKEN || ""; +const MATRIX_USER_ID = process.env.MATRIX_USER_ID || ""; +const MATRIX_SERVER_NAME = process.env.MATRIX_SERVER_NAME || ""; +const MATRIX_FEDERATION_TESTER = "https://federationtester.matrix.org/api/report"; + +let wrapRateLimited = null; +let getDb = null; + +async function loadSharedDeps() { + try { + const rl = await import("../../../servers/shared/rate-limiter.js"); + wrapRateLimited = rl.wrapRateLimited; + } catch { + wrapRateLimited = () => (_, h) => h; + } + try { + const db = await import("../../../servers/db.js"); + getDb = db.createDbClient; + } catch { + getDb = null; + } +} + +async function mxFetch(path, { method = "GET", body, noAuth, timeoutMs = 20_000 } = {}) { + const url = `${MATRIX_URL}${path}`; + const headers = { "Content-Type": "application/json" }; + if (!noAuth && MATRIX_ACCESS_TOKEN) { + headers.Authorization = `Bearer ${MATRIX_ACCESS_TOKEN}`; + } + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), timeoutMs); + try { + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: ctl.signal, + }); + const text = await res.text(); + if (!res.ok) { + const snippet = text.slice(0, 600); + if (res.status === 401) { + throw new Error(`Matrix auth failed (401). Set MATRIX_ACCESS_TOKEN. Obtain via POST /_matrix/client/v3/login.`); + } + if (res.status === 403) { + throw new Error(`Matrix forbidden (403)${snippet ? ": " + snippet : ""}`); + } + throw new Error(`Matrix ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}`); + } + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return { raw: text }; + } + } catch (err) { + if (err.name === "AbortError") throw new Error(`Matrix request timed out: ${path}`); + if (err.cause?.code === "ECONNREFUSED" || err.message?.includes("ECONNREFUSED")) { + throw new Error( + `Cannot reach Dendrite at ${MATRIX_URL}. Verify the container is up and on the crow-federation network (docker ps | grep crow-dendrite).`, + ); + } + throw err; + } finally { + clearTimeout(t); + } +} + +/** + * Resolve a room alias (#room:server) to an internal room ID (!id:server) + * via federation-aware directory lookup. Passes through if input is + * already an ID. + */ +async function resolveRoom(aliasOrId) { + if (aliasOrId.startsWith("!")) return aliasOrId; + if (aliasOrId.startsWith("#")) { + const out = await mxFetch(`/_matrix/client/v3/directory/room/${encodeURIComponent(aliasOrId)}`); + if (out.room_id) return out.room_id; + throw new Error(`Could not resolve alias ${aliasOrId}: ${JSON.stringify(out).slice(0, 200)}`); + } + throw new Error(`Not a Matrix room ID or alias: ${aliasOrId}`); +} + +function requireAuth() { + if (!MATRIX_ACCESS_TOKEN) { + return { content: [{ type: "text", text: "Error: MATRIX_ACCESS_TOKEN required. POST /_matrix/client/v3/login to obtain one." }] }; + } + return null; +} + +export async function createMatrixDendriteServer(options = {}) { + await loadSharedDeps(); + + const server = new McpServer( + { name: "crow-matrix-dendrite", version: "1.0.0" }, + { instructions: options.instructions }, + ); + + const limiter = wrapRateLimited + ? wrapRateLimited({ db: getDb ? getDb() : null }) + : (_, h) => h; + + // --- matrix_status --- + server.tool( + "matrix_status", + "Report Dendrite status: reachability, server version, whoami, federation mode (disabled/enabled), and the canonical Matrix federation tester verdict for this server.", + {}, + async () => { + try { + const [versions, whoami, caps] = await Promise.all([ + mxFetch("/_matrix/client/versions", { noAuth: true }), + MATRIX_ACCESS_TOKEN ? mxFetch("/_matrix/client/v3/account/whoami").catch(() => null) : Promise.resolve(null), + MATRIX_ACCESS_TOKEN ? mxFetch("/_matrix/client/v3/capabilities").catch(() => null) : Promise.resolve(null), + ]); + return { + content: [{ + type: "text", + text: JSON.stringify({ + server_name: MATRIX_SERVER_NAME || null, + url: MATRIX_URL, + versions: versions?.versions || null, + unstable_features: versions?.unstable_features || {}, + whoami: whoami || null, + room_version: caps?.capabilities?.["m.room_versions"]?.default || null, + has_access_token: Boolean(MATRIX_ACCESS_TOKEN), + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- matrix_joined_rooms --- + server.tool( + "matrix_joined_rooms", + "List rooms the authenticated user is in, with a name hint for each.", + {}, + async () => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const { joined_rooms = [] } = await mxFetch("/_matrix/client/v3/joined_rooms"); + const withNames = await Promise.all( + joined_rooms.slice(0, 100).map(async (rid) => { + const name = await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.name`).catch(() => null); + const canonical = await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/state/m.room.canonical_alias`).catch(() => null); + return { + room_id: rid, + name: name?.name || null, + canonical_alias: canonical?.alias || null, + }; + }), + ); + return { content: [{ type: "text", text: JSON.stringify({ count: joined_rooms.length, rooms: withNames }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- matrix_create_room --- + server.tool( + "matrix_create_room", + "Create a new room. Rate-limited: 10/hour.", + { + name: z.string().max(255).optional(), + topic: z.string().max(500).optional(), + alias_localpart: z.string().max(100).optional().describe("Local part of the canonical alias — 'chat' becomes #chat:yourdomain"), + visibility: z.enum(["public", "private"]).optional().describe("Directory visibility. Default private."), + preset: z.enum(["public_chat", "private_chat", "trusted_private_chat"]).optional(), + invite: z.array(z.string().max(320)).max(50).optional().describe("User IDs to invite at creation time."), + is_direct: z.boolean().optional().describe("Mark as a 1:1 DM room."), + }, + limiter("matrix_create_room", async (args) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const body = { + ...(args.name ? { name: args.name } : {}), + ...(args.topic ? { topic: args.topic } : {}), + ...(args.alias_localpart ? { room_alias_name: args.alias_localpart } : {}), + visibility: args.visibility || "private", + ...(args.preset ? { preset: args.preset } : {}), + ...(args.invite ? { invite: args.invite } : {}), + ...(args.is_direct != null ? { is_direct: args.is_direct } : {}), + }; + const out = await mxFetch("/_matrix/client/v3/createRoom", { method: "POST", body }); + return { content: [{ type: "text", text: JSON.stringify({ room_id: out.room_id, room_alias: out.room_alias || null }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- matrix_join_room --- + server.tool( + "matrix_join_room", + "Join a room by ID or alias. If the room lives on another server, Dendrite federates the join (may take several seconds). Rate-limited: 30/hour.", + { room: z.string().min(1).max(500).describe("Room ID (!id:server) or alias (#room:server)") }, + limiter("matrix_join_room", async ({ room }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + // /join/{roomIdOrAlias} handles both; URL-encode the whole thing + const out = await mxFetch(`/_matrix/client/v3/join/${encodeURIComponent(room)}`, { method: "POST", body: {} }); + return { content: [{ type: "text", text: JSON.stringify({ joined: out.room_id || room }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- matrix_leave_room --- + server.tool( + "matrix_leave_room", + "Leave a room. Destructive — your messages stay but you lose access. Rate-limited: 30/hour.", + { + room: z.string().min(1).max(500), + confirm: z.literal("yes"), + }, + limiter("matrix_leave_room", async ({ room }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const rid = await resolveRoom(room); + await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/leave`, { method: "POST", body: {} }); + return { content: [{ type: "text", text: JSON.stringify({ left: rid }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- matrix_send_message --- + server.tool( + "matrix_send_message", + "Send a text (or notice, or HTML-formatted) message to a room. Rate-limited: 20/hour.", + { + room: z.string().min(1).max(500), + body: z.string().min(1).max(20_000).describe("Plain-text body."), + formatted_body: z.string().max(50_000).optional().describe("Optional HTML-formatted body (org.matrix.custom.html)."), + msgtype: z.enum(["m.text", "m.notice", "m.emote"]).optional().describe("Default m.text."), + }, + limiter("matrix_send_message", async ({ room, body, formatted_body, msgtype }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const rid = await resolveRoom(room); + // Use PUT /send/{type}/{txnId} for idempotency; a random txn id works. + const txn = `crow-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const content = { + msgtype: msgtype || "m.text", + body, + ...(formatted_body ? { format: "org.matrix.custom.html", formatted_body } : {}), + }; + const out = await mxFetch( + `/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/send/m.room.message/${encodeURIComponent(txn)}`, + { method: "PUT", body: content }, + ); + return { content: [{ type: "text", text: JSON.stringify({ event_id: out.event_id, room_id: rid }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- matrix_room_messages --- + server.tool( + "matrix_room_messages", + "Paginated message history for a room. Returns the most recent N events by default.", + { + room: z.string().min(1).max(500), + limit: z.number().int().min(1).max(100).optional(), + from: z.string().max(300).optional().describe("Opaque pagination token; omit to start from the latest."), + direction: z.enum(["b", "f"]).optional().describe("b = backwards (default, newest-first), f = forwards."), + }, + async ({ room, limit, from, direction }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const rid = await resolveRoom(room); + const params = new URLSearchParams({ + limit: String(limit ?? 20), + dir: direction || "b", + }); + if (from) params.set("from", from); + const out = await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/messages?${params}`); + const summary = (out.chunk || []).map((e) => ({ + event_id: e.event_id, + type: e.type, + sender: e.sender, + ts: e.origin_server_ts, + body: e.content?.body, + msgtype: e.content?.msgtype, + })); + return { + content: [{ + type: "text", + text: JSON.stringify({ room_id: rid, count: summary.length, events: summary, next_from: out.end, prev_from: out.start }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- matrix_sync (one-shot) --- + server.tool( + "matrix_sync", + "One-shot sync with a short server-side timeout. Returns new events, invites, and joined-room deltas since `since` (or the full initial state if absent). For long-poll streaming use the panel's SSE bridge.", + { + since: z.string().max(500).optional().describe("Opaque since-token from a prior sync; omit for initial sync (heavy — use sparingly)."), + timeout_ms: z.number().int().min(0).max(30_000).optional().describe("Server-side long-poll timeout in ms. Default 1500 for MCP latency."), + }, + async ({ since, timeout_ms }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const params = new URLSearchParams({ timeout: String(timeout_ms ?? 1500) }); + if (since) params.set("since", since); + const out = await mxFetch(`/_matrix/client/v3/sync?${params}`, { timeoutMs: (timeout_ms || 1500) + 10_000 }); + // Shrink payload: just summarize room deltas instead of returning + // the entire rooms tree (full sync can be megabytes). + const joinedDelta = Object.entries(out.rooms?.join || {}).map(([rid, state]) => ({ + room_id: rid, + timeline_events: state.timeline?.events?.length || 0, + notification_count: state.unread_notifications?.notification_count || 0, + })); + const invites = Object.keys(out.rooms?.invite || {}); + return { + content: [{ + type: "text", + text: JSON.stringify({ next_batch: out.next_batch, joined_rooms_delta: joinedDelta, invites }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- matrix_invite_user --- + server.tool( + "matrix_invite_user", + "Invite a user to a room. Rate-limited: 10/hour.", + { + room: z.string().min(1).max(500), + user_id: z.string().min(3).max(320).describe("Full Matrix ID (@alice:example.com)"), + reason: z.string().max(500).optional(), + }, + limiter("matrix_invite_user", async ({ room, user_id, reason }) => { + try { + const authErr = requireAuth(); if (authErr) return authErr; + const rid = await resolveRoom(room); + const body = { user_id, ...(reason ? { reason } : {}) }; + await mxFetch(`/_matrix/client/v3/rooms/${encodeURIComponent(rid)}/invite`, { method: "POST", body }); + return { content: [{ type: "text", text: JSON.stringify({ invited: user_id, room_id: rid }, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }), + ); + + // --- matrix_register_appservice (F.12 prep) --- + server.tool( + "matrix_register_appservice", + "Register a Matrix appservice (used by F.12 matrix-bridges bundle). Writes the registration YAML into Dendrite's config dir. Does NOT restart Dendrite — the caller is responsible for a restart-with-health-wait since Dendrite only reloads appservice registrations at startup.", + { + id: z.string().min(1).max(100).describe("Unique appservice id (e.g., 'mautrix-signal')."), + url: z.string().url().max(500).describe("Bridge URL Dendrite will push events to."), + hs_token: z.string().min(16).max(200).describe("Token Dendrite uses to authenticate to the bridge."), + as_token: z.string().min(16).max(200).describe("Token the bridge uses to authenticate to Dendrite."), + sender_localpart: z.string().min(1).max(100).describe("User localpart that represents the bridge bot."), + namespace_users: z.array(z.string().max(200)).max(20).optional().describe("Regex list of user-ID patterns the bridge owns."), + namespace_aliases: z.array(z.string().max(200)).max(20).optional().describe("Regex list of room-alias patterns the bridge owns."), + namespace_rooms: z.array(z.string().max(200)).max(20).optional().describe("Regex list of room-ID patterns the bridge owns."), + protocols: z.array(z.string().max(100)).max(10).optional().describe("Third-party protocols the bridge announces."), + rate_limited: z.boolean().optional(), + }, + async (args) => { + try { + // We don't directly write the file from the MCP server (we don't + // have host filesystem access to Dendrite's config dir). Instead + // we return the YAML + the intended path, and the bundle's + // post-install / bridges meta-bundle writes it via an exec into + // the dendrite container. This keeps the surface declarative. + const yaml = [ + `# crow-generated appservice registration for ${args.id}`, + `id: ${args.id}`, + `url: ${args.url}`, + `as_token: ${args.as_token}`, + `hs_token: ${args.hs_token}`, + `sender_localpart: ${args.sender_localpart}`, + `rate_limited: ${args.rate_limited ?? false}`, + `namespaces:`, + ` users:`, + ...(args.namespace_users || []).map((r) => ` - exclusive: true\n regex: ${JSON.stringify(r)}`), + ...(!args.namespace_users?.length ? [" []"] : []), + ` aliases:`, + ...(args.namespace_aliases || []).map((r) => ` - exclusive: true\n regex: ${JSON.stringify(r)}`), + ...(!args.namespace_aliases?.length ? [" []"] : []), + ` rooms:`, + ...(args.namespace_rooms || []).map((r) => ` - exclusive: false\n regex: ${JSON.stringify(r)}`), + ...(!args.namespace_rooms?.length ? [" []"] : []), + ...(args.protocols?.length ? [`protocols:`, ...args.protocols.map((p) => ` - ${p}`)] : []), + "", + ].join("\n"); + + return { + content: [{ + type: "text", + text: JSON.stringify({ + install_path: `/etc/dendrite/appservices/${args.id}.yaml`, + yaml, + next_steps: [ + `docker cp crow-dendrite:/etc/dendrite/appservices/${args.id}.yaml`, + `edit /etc/dendrite/dendrite.yaml to add 'app_service_api: { config_files: [appservices/${args.id}.yaml] }'`, + "docker compose -f bundles/matrix-dendrite/docker-compose.yml restart dendrite", + "(Dendrite reads appservice registrations only at startup.)", + ], + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + // --- matrix_federation_health --- + server.tool( + "matrix_federation_health", + "Ask the public Matrix Federation Tester (federationtester.matrix.org) whether this server federates correctly. Exercises both .well-known delegation and :8448 reachability — the canonical test for new Matrix installs.", + { + server_name: z.string().max(253).optional().describe("Override MATRIX_SERVER_NAME (e.g., to test a second domain)."), + }, + async ({ server_name }) => { + try { + const target = server_name || MATRIX_SERVER_NAME; + if (!target) { + return { content: [{ type: "text", text: "Error: server_name required (set MATRIX_SERVER_NAME or pass explicitly)." }] }; + } + const url = `${MATRIX_FEDERATION_TESTER}?server_name=${encodeURIComponent(target)}`; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), 30_000); + try { + const res = await fetch(url, { signal: ctl.signal }); + const json = await res.json(); + return { + content: [{ + type: "text", + text: JSON.stringify({ + server_name: target, + federation_ok: json.FederationOK, + dns_result: json.DNSResult?.Hosts ? Object.keys(json.DNSResult.Hosts) : null, + well_known_result: json.WellKnownResult?.["m.server"] || null, + connection_report_count: Object.keys(json.ConnectionReports || {}).length, + errors: json.Errors || [], + warnings: json.Warnings || [], + }, null, 2), + }], + }; + } finally { + clearTimeout(timer); + } + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + }, + ); + + return server; +} diff --git a/bundles/matrix-dendrite/skills/matrix-dendrite.md b/bundles/matrix-dendrite/skills/matrix-dendrite.md new file mode 100644 index 0000000..16d89cb --- /dev/null +++ b/bundles/matrix-dendrite/skills/matrix-dendrite.md @@ -0,0 +1,174 @@ +--- +name: matrix-dendrite +description: Matrix homeserver on Dendrite — federated real-time chat with E2EE. Rooms, messages, sync, federation health. +triggers: + - "matrix" + - "dendrite" + - "matrix room" + - "#room:server" + - "@user:server" + - "join matrix" + - "send matrix message" + - "e2ee chat" +tools: + - matrix_status + - matrix_joined_rooms + - matrix_create_room + - matrix_join_room + - matrix_leave_room + - matrix_send_message + - matrix_room_messages + - matrix_sync + - matrix_invite_user + - matrix_register_appservice + - matrix_federation_health +--- + +# Matrix on Dendrite — federated real-time chat + +Dendrite is a lightweight Go-based Matrix homeserver (vs. Synapse's Python). It speaks the full Matrix v3 client-server API and federates over the Matrix graph. This bundle runs Dendrite + Postgres in two containers on the shared `crow-federation` network. + +## Hardware + +Gated by F.0's hardware check. Refused on hosts with less than **2 GB effective RAM after committed bundles + 512 MB host reserve**. Recommended 4 GB+; Pi class (4-8 GB total) will get warnings unless you're the only federated bundle installed. Disk grows fast under federated joins — plan for 100 GB+ if you join any major room (Matrix HQ alone is tens of GB of state). + +## The federation port decision — pick one + +Matrix federation happens on port **8448/tcp**. You have two mutually-exclusive ways to satisfy this: + +### Option A: open :8448 on the router + +``` +caddy_add_matrix_federation_port { + "domain": "matrix.example.com", + "upstream_8448": "dendrite:8448" +} +``` + +Caddy requests a second Let's Encrypt cert for `matrix.example.com:8448` and reverse-proxies into Dendrite. Requires your router/firewall forward 8448/tcp to this host. + +### Option B: apex `.well-known/matrix/server` delegation + +``` +caddy_set_wellknown { + "domain": "example.com", + "kind": "matrix-server", + "opts": { "delegate_to": "matrix.example.com:443" } +} +``` + +This publishes `/.well-known/matrix/server` on the apex declaring that Matrix federation for `@user:example.com` lives at `matrix.example.com:443`. No :8448 needed — federation rides HTTPS on 443. + +**Either works. Not both.** `caddy_add_matrix_federation_port` refuses to run when the same domain already has matrix-server delegation — the F.0 tool enforces this. + +Pair whichever path you chose with the client-server proxy: + +``` +caddy_add_federation_site { + "domain": "matrix.example.com", + "upstream": "dendrite:8008", + "profile": "matrix" +} +``` + +Verify the whole setup end-to-end with: + +``` +matrix_federation_health { "server_name": "example.com" } +``` + +This calls the public Matrix Federation Tester and returns its structured verdict. + +## First-run bootstrap + +The entrypoint generates a signing key and `dendrite.yaml` on first boot, then prints the registration shared secret to the container log: + +``` +Registration shared secret: <48-char base64> +``` + +1. Copy that secret into `.env` as `MATRIX_REGISTRATION_SHARED_SECRET`. +2. Register the admin account: + ```bash + docker exec crow-dendrite \ + create-account --config /etc/dendrite/dendrite.yaml \ + --username admin --password '' --admin + ``` +3. Log in to get a client-server access token: + ```bash + curl -X POST https://matrix.example.com/_matrix/client/v3/login \ + -H 'Content-Type: application/json' \ + -d '{"type":"m.login.password","user":"admin","password":""}' + ``` +4. Paste `access_token` and `user_id` into `.env` as `MATRIX_ACCESS_TOKEN` and `MATRIX_USER_ID`, then restart the MCP server. + +## Common workflows + +### Create a room + +``` +matrix_create_room { + "name": "Project Crow", + "topic": "Development chat", + "visibility": "private", + "preset": "private_chat", + "invite": ["@alice:matrix.org"] +} +``` + +### Join a federated room + +``` +matrix_join_room { "room": "#matrix:matrix.org" } +``` + +First federation join against a given remote server can take 10+ seconds while Dendrite fetches the room state. Subsequent joins to rooms on that server are fast. + +### Send a message + +``` +matrix_send_message { + "room": "#crow-dev:example.com", + "body": "hello, matrix" +} +``` + +For HTML / formatted messages add `formatted_body` (HTML) alongside `body` (fallback plaintext). + +### Read recent history + +``` +matrix_room_messages { "room": "#crow-dev:example.com", "limit": 20 } +``` + +Paginate back further by passing the returned `next_from` as the next call's `from`. + +### One-shot sync (poll, not stream) + +``` +matrix_sync {} # initial (heavy — use sparingly) +matrix_sync { "since": "s42_15_0_1" } # delta +``` + +Matrix has a long-polling sync endpoint; `matrix_sync` returns a compact summary (joined-room deltas + invite count + `next_batch`) rather than the full tree. For a real streaming feed use the panel's SSE bridge. + +## Encryption + +Dendrite supports E2EE, but key material lives in each client (your Element / Fluffychat / etc.). The Crow MCP server does NOT handle encryption keys — it posts plaintext events. For encrypted rooms, you need a dedicated Matrix client with device keys; use Dendrite as the homeserver but let Element handle the ciphertext. Posting an MCP message to an E2EE room will send plaintext (visible only to you on your devices; other members will see an unencrypted event). + +## Moderation + +Matrix moderation is room-scoped (not instance-scoped like Mastodon/GoToSocial). Admins can ban users from specific rooms via Matrix's standard membership events. Instance-wide federation blocklists (server ACLs) are a room-level feature — ban a remote server from a specific room via `m.room.server_acl` state events. + +This bundle does not yet expose moderation verbs; room bans + server ACLs land in a follow-up once the full moderation taxonomy is exercised. For now, use a Matrix client (Element) for moderation actions. + +## F.12 appservice prep + +`matrix_register_appservice` produces a YAML registration file + write-it-into-dendrite instructions. This is the entry point the F.12.1 matrix-bridges meta-bundle uses to install mautrix-signal / mautrix-telegram / mautrix-whatsapp. You won't typically invoke this directly — install a bridge, and the bridge's post-install calls this tool then restarts Dendrite for you. + +## Troubleshooting + +- **"Matrix request timed out"** — Dendrite is often slow on first boot (DB migrations + key bootstrap). First-run healthcheck has a 60s grace period. If the timeout persists, check `docker logs crow-dendrite` for signing-key errors. +- **Federation tester says "No address found"** — neither `.well-known/matrix/server` nor `:8448` reachable. Run `matrix_federation_health` again after DNS propagates; give it 2–3 minutes. +- **"Cert for :8448 is staging"** — the F.0 `caddy_cert_health` surfaces this. Matrix peers reject staging certs; wait for real cert issuance. +- **Disk growing fast after joining Matrix HQ** — expected. Matrix backfills room state + media aggressively. Consider unjoining large rooms or purging media via Dendrite admin API (not yet wired as an MCP tool). diff --git a/registry/add-ons.json b/registry/add-ons.json index 19ca52e..64b48e7 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -3110,6 +3110,46 @@ "webUI": null, "notes": "No host port publish. After install: caddy_add_federation_site { domain: WF_HOST, upstream: 'writefreely:8080', profile: 'activitypub' }. Admin account is created via the first-run web UI (no CLI bootstrap)." }, + { + "id": "matrix-dendrite", + "name": "Matrix (Dendrite)", + "description": "Matrix homeserver on Dendrite — federated real-time chat with end-to-end encryption. Lighter than Synapse, monolithic binary. Joins the Matrix federation graph via HTTPS federation (:8448 or .well-known delegation).", + "type": "bundle", + "version": "1.0.0", + "author": "Crow", + "category": "federated-comms", + "tags": ["matrix", "dendrite", "chat", "federation", "e2ee", "realtime"], + "icon": "message-circle", + "docker": { "composefile": "docker-compose.yml" }, + "server": { + "command": "node", + "args": ["server/index.js"], + "envKeys": ["MATRIX_URL", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_SERVER_NAME"] + }, + "panel": "panel/matrix-dendrite.js", + "panelRoutes": "panel/routes.js", + "skills": ["skills/matrix-dendrite.md"], + "consent_required": true, + "requires": { + "env": ["MATRIX_SERVER_NAME", "MATRIX_POSTGRES_PASSWORD"], + "bundles": ["caddy"], + "min_ram_mb": 2048, + "recommended_ram_mb": 4096, + "min_disk_mb": 10000, + "recommended_disk_mb": 100000 + }, + "env_vars": [ + { "name": "MATRIX_SERVER_NAME", "description": "Public homeserver domain (appears in user IDs as @alice:).", "required": true }, + { "name": "MATRIX_HOST", "description": "Domain Caddy reverse-proxies the client-server API on. May differ from MATRIX_SERVER_NAME when using .well-known delegation.", "required": false }, + { "name": "MATRIX_POSTGRES_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true }, + { "name": "MATRIX_ACCESS_TOKEN", "description": "Admin access token (from POST /_matrix/client/v3/login after registering the admin via the CLI).", "required": false, "secret": true }, + { "name": "MATRIX_USER_ID", "description": "Admin user's full Matrix ID (@admin:example.com).", "required": false }, + { "name": "MATRIX_REGISTRATION_SHARED_SECRET", "description": "Dendrite's registration shared secret (printed to logs on first boot).", "required": false, "secret": true } + ], + "ports": [], + "webUI": null, + "notes": "Two containers (dendrite + postgres). Federation: pick EITHER caddy_add_matrix_federation_port (router forwards :8448) OR caddy_set_wellknown matrix-server on the apex. Initial admin registered via `docker exec crow-dendrite create-account`." + }, { "id": "developer-kit", "name": "Developer Kit", diff --git a/skills/superpowers.md b/skills/superpowers.md index a17e36f..0850f29 100644 --- a/skills/superpowers.md +++ b/skills/superpowers.md @@ -81,6 +81,7 @@ This is the master routing skill. Consult this **before every task** to determin | "schedule", "remind me", "every day at", "recurring" | "programar", "recuérdame", "cada día a las" | scheduling | crow-memory | | "toot", "post to fediverse", "follow @user@...", "mastodon", "gotosocial", "activitypub" | "publicar en fediverso", "tootear", "seguir @usuario@...", "mastodon", "gotosocial" | gotosocial | crow-gotosocial | | "writefreely", "federated blog", "long-form post", "publish article", "blog to fediverse" | "writefreely", "blog federado", "artículo largo", "publicar al fediverso" | writefreely | crow-writefreely | +| "matrix", "dendrite", "join #room:server", "send @user:server", "e2ee chat", "matrix room" | "matrix", "dendrite", "unirse a #sala:servidor", "mensaje a @usuario:servidor", "chat e2ee" | matrix-dendrite | crow-matrix-dendrite | | "tutor me", "teach me", "quiz me", "help me understand" | "enséñame", "explícame", "evalúame" | tutoring | crow-memory | | "wrap up", "summarize session", "what did we do" | "resumir sesión", "qué hicimos" | session-summary | crow-memory | | "change language", "speak in..." | "cambiar idioma", "háblame en..." | i18n | crow-memory |