diff --git a/bundles/caddy/docker-compose.yml b/bundles/caddy/docker-compose.yml index 4f874ec..00b5945 100644 --- a/bundles/caddy/docker-compose.yml +++ b/bundles/caddy/docker-compose.yml @@ -17,10 +17,20 @@ # via XDG_DATA_HOME). We chmod 0700 on first init. # config/ — Caddy's auto-save state (XDG_CONFIG_HOME) +networks: + crow-federation: + external: true + # Created by bundles/caddy/scripts/post-install.sh on install. + # Federated app bundles (F.1+) join this same network so Caddy can reach + # their upstreams by docker service name without publishing host ports. + services: caddy: image: caddy:2-alpine container_name: crow-caddy + networks: + - default + - crow-federation ports: - "0.0.0.0:80:80" - "0.0.0.0:443:443" diff --git a/bundles/caddy/panel/caddy.js b/bundles/caddy/panel/caddy.js index 5986652..4ec4696 100644 --- a/bundles/caddy/panel/caddy.js +++ b/bundles/caddy/panel/caddy.js @@ -33,6 +33,11 @@ export default {
Loading status…
+
+

Certificate Health

+
Loading…
+
+

Sites

Loading…
@@ -222,9 +227,64 @@ function script() { } catch (e) { alert('Remove failed: ' + e.message); } } + async function loadCerts() { + const el = document.getElementById('cd-certs'); + clearNode(el); + try { + const res = await fetch('/api/caddy/cert-health'); + const data = await res.json(); + if (data.error) { el.appendChild(errorNode(data.error)); return; } + if (!data.results || data.results.length === 0) { + const d = document.createElement('div'); + d.className = 'np-idle'; + d.textContent = 'No sites configured yet — add one below and Caddy will request a certificate on first request.'; + el.appendChild(d); + return; + } + const card = document.createElement('div'); + card.className = 'cd-card'; + const summary = document.createElement('div'); + summary.className = 'cd-cert-summary cd-cert-' + data.summary; + summary.textContent = 'Overall: ' + data.summary.toUpperCase(); + card.appendChild(summary); + + for (const r of data.results) { + const row = document.createElement('div'); + row.className = 'cd-cert-row cd-cert-' + r.status; + const head = document.createElement('div'); + head.className = 'cd-cert-head'; + const dot = document.createElement('span'); + dot.className = 'cd-cert-dot cd-cert-dot-' + r.status; + dot.textContent = r.status === 'ok' ? '\u2713' : r.status === 'warning' ? '!' : '\u2717'; + head.appendChild(dot); + const dom = document.createElement('b'); + dom.textContent = r.domain; + head.appendChild(dom); + row.appendChild(head); + + const meta = document.createElement('div'); + meta.className = 'cd-cert-meta'; + meta.textContent = r.issuer + (r.expires_at ? ' \u2022 expires ' + new Date(r.expires_at).toLocaleDateString() : ''); + row.appendChild(meta); + + if (r.problems && r.problems.length) { + const p = document.createElement('div'); + p.className = 'cd-cert-problems'; + p.textContent = r.problems.join('; '); + row.appendChild(p); + } + card.appendChild(row); + } + el.appendChild(card); + } catch (e) { + el.appendChild(errorNode('Cannot load cert health: ' + e.message)); + } + } + document.getElementById('cd-add').addEventListener('submit', cdAdd); loadStatus(); loadSites(); + loadCerts(); `; } @@ -256,5 +316,25 @@ function styles() { background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } .np-error { color: var(--crow-error, #ef4444); font-size: 0.9rem; padding: 1rem; background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; } + + .cd-cert-summary { font-size: .85rem; font-weight: 600; text-transform: uppercase; + letter-spacing: .05em; padding: .35rem .6rem; border-radius: 6px; + display: inline-block; margin-bottom: .8rem; } + .cd-cert-summary.cd-cert-ok { background: rgba(34,197,94,.15); color: #22c55e; } + .cd-cert-summary.cd-cert-warning { background: rgba(234,179,8,.15); color: #eab308; } + .cd-cert-summary.cd-cert-error { background: rgba(239,68,68,.15); color: #ef4444; } + .cd-cert-row { padding: .6rem 0; border-top: 1px solid var(--crow-border); } + .cd-cert-row:first-of-type { border-top: none; } + .cd-cert-head { display: flex; align-items: center; gap: .5rem; } + .cd-cert-head b { font-size: .95rem; color: var(--crow-text-primary); } + .cd-cert-dot { display: inline-flex; align-items: center; justify-content: center; + width: 1.2rem; height: 1.2rem; border-radius: 50%; font-size: .75rem; + font-weight: bold; } + .cd-cert-dot-ok { background: #22c55e; color: #0b0d10; } + .cd-cert-dot-warning { background: #eab308; color: #0b0d10; } + .cd-cert-dot-error { background: #ef4444; color: #fff; } + .cd-cert-meta { font-size: .8rem; color: var(--crow-text-muted); margin-top: .2rem; + margin-left: 1.7rem; font-family: ui-monospace, monospace; } + .cd-cert-problems { font-size: .8rem; color: #ef4444; margin-top: .15rem; margin-left: 1.7rem; } `; } diff --git a/bundles/caddy/panel/routes.js b/bundles/caddy/panel/routes.js index 9535160..634823f 100644 --- a/bundles/caddy/panel/routes.js +++ b/bundles/caddy/panel/routes.js @@ -142,6 +142,69 @@ export default function caddyRouter(authMiddleware) { } }); + router.get("/api/caddy/cert-health", authMiddleware, async (req, res) => { + try { + const config = await adminGet("/config/"); + const policies = config?.apps?.tls?.automation?.policies || []; + const servers = config?.apps?.http?.servers || {}; + + const domains = new Set(); + for (const srv of Object.values(servers)) { + for (const route of srv.routes || []) { + for (const m of route.match || []) { + for (const h of m.host || []) domains.add(h); + } + } + } + if (req.query.domain && domainLike(req.query.domain)) { + const d = req.query.domain; + if (!domains.has(d)) return res.json({ results: [], summary: "ok" }); + domains.clear(); + domains.add(d); + } + + const stagingFragment = "acme-staging-v02.api.letsencrypt.org"; + const results = []; + for (const host of domains) { + const policy = policies.find((p) => !p.subjects || p.subjects.includes(host)) || policies[0]; + const issuer = policy?.issuers?.[0] || {}; + const isStaging = typeof issuer.ca === "string" && issuer.ca.includes(stagingFragment); + const issuerName = isStaging + ? "Let's Encrypt (STAGING)" + : (issuer.module || "acme") + (issuer.ca ? ` (${issuer.ca})` : ""); + + let expiresAt = null; + let status = "warning"; + const problems = []; + try { + const info = await adminGet(`/pki/ca/local/certificates/${encodeURIComponent(host)}`).catch(() => null); + if (info?.not_after) { + expiresAt = info.not_after; + const days = (new Date(expiresAt).getTime() - Date.now()) / 86400_000; + if (days < 7) { status = "error"; problems.push(`expires in ${days.toFixed(1)} days`); } + else if (days < 30) { status = "warning"; problems.push(`expires in ${days.toFixed(0)} days`); } + else { status = "ok"; } + } else { + problems.push("no cert loaded"); + } + } catch (err) { + problems.push(`lookup failed: ${err.message}`); + } + if (isStaging) { + if (status === "ok") status = "warning"; + problems.push("ACME staging issuer in use"); + } + results.push({ domain: host, status, issuer: issuerName, expires_at: expiresAt, problems }); + } + + const anyError = results.some((r) => r.status === "error"); + const anyWarn = results.some((r) => r.status === "warning"); + res.json({ summary: anyError ? "error" : anyWarn ? "warning" : "ok", results }); + } catch (err) { + res.json({ error: err.message }); + } + }); + router.post("/api/caddy/reload", authMiddleware, async (_req, res) => { try { const source = readCaddyfile(CONFIG_DIR()); diff --git a/bundles/caddy/scripts/post-install.sh b/bundles/caddy/scripts/post-install.sh new file mode 100755 index 0000000..999d076 --- /dev/null +++ b/bundles/caddy/scripts/post-install.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Caddy bundle post-install hook. +# +# Creates the `crow-federation` external docker network that federated app +# bundles (F.1 onward) join. Idempotent: existing network is left alone. +# +# Wired into the installer via the bundle lifecycle — see +# servers/gateway/routes/bundles.js which runs scripts/post-install.sh (if +# present) after `docker compose up -d` succeeds. + +set -euo pipefail + +NETWORK="crow-federation" + +if docker network inspect "$NETWORK" >/dev/null 2>&1; then + echo "docker network $NETWORK already exists" + exit 0 +fi + +docker network create --driver bridge "$NETWORK" +echo "created docker network $NETWORK" diff --git a/bundles/caddy/server/caddyfile.js b/bundles/caddy/server/caddyfile.js index c084c40..c59c73a 100644 --- a/bundles/caddy/server/caddyfile.js +++ b/bundles/caddy/server/caddyfile.js @@ -187,6 +187,36 @@ export function appendSite(source, domain, upstream, extra = "") { return base + sep + block; } +/** + * Append a pre-rendered site block (address + body already formatted). + * Body text is indented with two spaces per line. Used by + * caddy_add_federation_site and caddy_add_matrix_federation_port where the + * inner directives include nested blocks that don't fit the simple + * `reverse_proxy ` shape. + * + * If a block with the same address already exists, it is replaced in place + * (idempotent emit — reviewer requirement for federation profiles). + */ +export function upsertRawSite(source, address, bodyText) { + const indented = bodyText + .split("\n") + .map((l) => (l.length ? " " + l : "")) + .join("\n"); + const block = `${address} {\n${indented}\n}\n`; + + const sites = parseSites(source); + const match = sites.find((s) => s.address === address); + if (match) { + const before = source.slice(0, match.start); + const after = source.slice(match.end); + const joined = (before + block + after).replace(/\n{3,}/g, "\n\n"); + return joined; + } + const base = source.endsWith("\n") || source === "" ? source : source + "\n"; + const sep = base && !base.endsWith("\n\n") ? "\n" : ""; + return base + sep + block; +} + /** * Remove a site block matching the given address. * If multiple blocks match (rare), only the first is removed. diff --git a/bundles/caddy/server/federation-profiles.js b/bundles/caddy/server/federation-profiles.js new file mode 100644 index 0000000..7db5e2f --- /dev/null +++ b/bundles/caddy/server/federation-profiles.js @@ -0,0 +1,166 @@ +/** + * Federation profiles for Caddy. + * + * Each profile encodes the Caddyfile directives a federated app needs beyond + * a plain `reverse_proxy`: websocket upgrades, large upload bodies, longer + * proxy timeouts, and optional /.well-known/ handlers for actor/server + * discovery. Profiles are designed to be idempotent — re-running + * caddy_add_federation_site with the same domain replaces the block, never + * duplicates. + * + * Profiles: + * matrix — Matrix client-server on HTTPS. Pair with + * caddy_add_matrix_federation_port for :8448 OR + * caddy_set_wellknown with matrix-server delegation. + * activitypub — Mastodon / GoToSocial / Pixelfed / WriteFreely / + * Funkwhale / Lemmy / BookWyrm / Mobilizon. Emits webfinger + * + host-meta + nodeinfo well-known handlers. + * peertube — PeerTube. Large body (8 GiB), long timeouts for uploads. + * generic-ws — Generic HTTP + websocket upgrade. Escape hatch. + */ + +/** + * Directive lines emitted inside a site block for each profile. + * Each string is one line, leading whitespace stripped; appendSite indents. + */ +const PROFILE_DIRECTIVES = { + matrix: [ + // Matrix client-server: large body for media, keep-alive for sync long-poll. + "request_body {", + " max_size 50MB", + "}", + "reverse_proxy {upstream} {", + " transport http {", + " versions 1.1 2", + " read_timeout 600s", + " }", + "}", + ], + activitypub: [ + // ActivityPub servers: 40 MB media ceiling covers Mastodon's default, + // GoToSocial's default, Pixelfed's typical. Websocket upgrade for + // Mastodon streaming API and GoToSocial's streaming. + "request_body {", + " max_size 40MB", + "}", + "reverse_proxy {upstream} {", + " header_up Host {host}", + " header_up X-Real-IP {remote_host}", + " header_up X-Forwarded-For {remote_host}", + " header_up X-Forwarded-Proto {scheme}", + " transport http {", + " read_timeout 300s", + " }", + "}", + ], + peertube: [ + // PeerTube: 8 GiB body for direct uploads, longer timeouts for + // transcoded streaming responses. + "request_body {", + " max_size 8GB", + "}", + "reverse_proxy {upstream} {", + " header_up Host {host}", + " header_up X-Real-IP {remote_host}", + " header_up X-Forwarded-For {remote_host}", + " header_up X-Forwarded-Proto {scheme}", + " transport http {", + " read_timeout 1800s", + " write_timeout 1800s", + " }", + "}", + ], + "generic-ws": [ + "reverse_proxy {upstream}", + ], +}; + +export const SUPPORTED_PROFILES = Object.keys(PROFILE_DIRECTIVES); + +/** + * Render the directives for a profile with the upstream substituted. + * Returns a multi-line string with no site-block wrapper and no leading + * indent; the Caddyfile writer indents each line as part of the block body. + */ +export function renderProfileDirectives(profile, upstream) { + const template = PROFILE_DIRECTIVES[profile]; + if (!template) { + throw new Error( + `Unknown federation profile "${profile}". Supported: ${SUPPORTED_PROFILES.join(", ")}`, + ); + } + return template.map((line) => line.replace("{upstream}", upstream)).join("\n"); +} + +/** + * Canonical JSON payloads for the most common .well-known handlers. + * Operators may override with their own JSON via caddy_set_wellknown. + * + * `matrix-server` — delegates Matrix federation to a different host/port + * (used instead of opening :8448). + * `matrix-client` — points Matrix clients at the homeserver URL. + * `nodeinfo` — NodeInfo 2.0 discovery doc for ActivityPub servers. + */ +export function buildWellKnownJson(kind, opts = {}) { + switch (kind) { + case "matrix-server": { + const target = opts.delegate_to; + if (!target) { + throw new Error(`matrix-server requires opts.delegate_to (e.g., "matrix.example.com:443")`); + } + return JSON.stringify({ "m.server": target }); + } + case "matrix-client": { + const base = opts.homeserver_base_url; + if (!base) { + throw new Error(`matrix-client requires opts.homeserver_base_url (e.g., "https://matrix.example.com")`); + } + const body = { "m.homeserver": { base_url: base } }; + if (opts.identity_server_base_url) { + body["m.identity_server"] = { base_url: opts.identity_server_base_url }; + } + return JSON.stringify(body); + } + case "nodeinfo": { + const href = opts.href; + if (!href) { + throw new Error(`nodeinfo requires opts.href (e.g., "https://masto.example.com/nodeinfo/2.0")`); + } + return JSON.stringify({ + links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href }], + }); + } + default: + throw new Error(`Unknown well-known kind "${kind}". Known: matrix-server, matrix-client, nodeinfo`); + } +} + +/** + * Reserved path prefixes under /.well-known/ for each app kind. Used to + * build the `handle` directives caddy_add_federation_site emits when the + * caller passes `wellknown: { matrix-server: {...}, nodeinfo: {...} }`. + */ +export const WELLKNOWN_PATHS = { + "matrix-server": "/.well-known/matrix/server", + "matrix-client": "/.well-known/matrix/client", + nodeinfo: "/.well-known/nodeinfo", + "host-meta": "/.well-known/host-meta", + webfinger: "/.well-known/webfinger", +}; + +/** + * Render a `handle ` block that returns a static JSON body. + * Emitted inside the main site block. Caddy serves it with correct + * Content-Type and lets the reverse_proxy handle everything else. + */ +export function renderWellKnownHandle(path, jsonBody) { + // Caddy's respond directive needs the body on a single line or quoted. + // We escape embedded double quotes, then wrap the body in double quotes. + const escaped = jsonBody.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return [ + `handle ${path} {`, + ` header Content-Type application/json`, + ` respond "${escaped}" 200`, + `}`, + ].join("\n"); +} diff --git a/bundles/caddy/server/server.js b/bundles/caddy/server/server.js index 4c9711c..c864b79 100644 --- a/bundles/caddy/server/server.js +++ b/bundles/caddy/server/server.js @@ -26,9 +26,18 @@ import { writeCaddyfile, parseSites, appendSite, + upsertRawSite, removeSite, } from "./caddyfile.js"; +import { + SUPPORTED_PROFILES, + renderProfileDirectives, + renderWellKnownHandle, + buildWellKnownJson, + WELLKNOWN_PATHS, +} from "./federation-profiles.js"; + const CADDY_ADMIN_URL = () => (process.env.CADDY_ADMIN_URL || "http://127.0.0.1:2019").replace(/\/+$/, ""); const CONFIG_DIR = () => resolveConfigDir(process.env.CADDY_CONFIG_DIR); @@ -306,5 +315,292 @@ export function createCaddyServer(options = {}) { } ); + // --- caddy_add_federation_site --- + server.tool( + "caddy_add_federation_site", + "Add a federation-aware reverse-proxy site block. Emits directives for the chosen profile (matrix | activitypub | peertube | generic-ws) — websocket upgrade, large request body, proxy timeouts — plus optional /.well-known/ handlers. Idempotent: re-running with the same domain replaces the existing block.", + { + domain: z.string().min(1).max(253).describe("Public site address (e.g., masto.example.com). Must be a subdomain dedicated to this federated app — ActivityPub actors are URL-keyed and subpath mounts break federation."), + upstream: z.string().min(1).max(500).describe("Upstream the app is reachable at (e.g., gotosocial:8080 over the shared crow-federation docker network, or 127.0.0.1:8080 for host-published debug mode)."), + profile: z.enum(SUPPORTED_PROFILES).describe(`One of: ${SUPPORTED_PROFILES.join(" | ")}`), + wellknown: z.record(z.string(), z.object({}).passthrough()).optional().describe(`Optional map of well-known handlers to emit inside this site block. Keys: ${Object.keys(WELLKNOWN_PATHS).join(", ")}. Values are either { body_json: "" } or kind-specific opts (matrix-server: { delegate_to }, matrix-client: { homeserver_base_url, identity_server_base_url? }, nodeinfo: { href }).`), + }, + async ({ domain, upstream, profile, wellknown }) => { + try { + if (!domainLike(domain)) { + return { content: [{ type: "text", text: "Error: domain contains disallowed characters (whitespace, braces, or newlines)." }] }; + } + if (!domainLike(upstream)) { + return { content: [{ type: "text", text: "Error: upstream contains disallowed characters." }] }; + } + if (domain.includes("/")) { + return { content: [{ type: "text", text: `Error: federation sites must be a bare subdomain (e.g., "masto.example.com"), not a subpath. ActivityPub actors are URL-keyed and subpath mounts break federation.` }] }; + } + + let body = renderProfileDirectives(profile, upstream); + + if (wellknown && Object.keys(wellknown).length) { + const handles = []; + for (const [kind, opts] of Object.entries(wellknown)) { + const path = WELLKNOWN_PATHS[kind]; + if (!path) { + return { content: [{ type: "text", text: `Error: unknown well-known kind "${kind}". Known: ${Object.keys(WELLKNOWN_PATHS).join(", ")}` }] }; + } + let jsonBody; + if (opts && typeof opts.body_json === "string") { + jsonBody = opts.body_json; + } else { + jsonBody = buildWellKnownJson(kind, opts || {}); + } + handles.push(renderWellKnownHandle(path, jsonBody)); + } + body = handles.join("\n") + "\n" + body; + } + + const source = readCaddyfile(CONFIG_DIR()); + const next = upsertRawSite(source, domain, body); + await loadCaddyfile(next); + writeCaddyfile(CONFIG_DIR(), next); + + const sites = parseSites(next); + const replaced = parseSites(source).some((s) => s.address === domain); + return { + content: [{ + type: "text", + text: `${replaced ? "Replaced" : "Added"} federation site ${domain} → ${upstream} (profile: ${profile}). ${sites.length} total site block(s). Caddy will request a Let's Encrypt cert on first request.`, + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + + // --- caddy_set_wellknown --- + server.tool( + "caddy_set_wellknown", + "Add or replace a standalone /.well-known/ handler at a domain that does NOT otherwise proxy to the federated app. Use this on an apex domain to delegate Matrix federation via `/.well-known/matrix/server` when Matrix itself lives on a different host. Idempotent.", + { + domain: z.string().min(1).max(253).describe("Apex or subdomain that serves the well-known JSON (e.g., example.com)."), + kind: z.enum(Object.keys(WELLKNOWN_PATHS)).describe(`One of: ${Object.keys(WELLKNOWN_PATHS).join(" | ")}`), + opts: z.record(z.string(), z.any()).optional().describe("Kind-specific options. matrix-server: { delegate_to: 'matrix.example.com:443' }. matrix-client: { homeserver_base_url: 'https://matrix.example.com' }. nodeinfo: { href: '...' }."), + body_json: z.string().max(5000).optional().describe("Override the canned JSON body entirely. Must be valid JSON."), + }, + async ({ domain, kind, opts, body_json }) => { + try { + if (!domainLike(domain)) { + return { content: [{ type: "text", text: "Error: domain contains disallowed characters." }] }; + } + const path = WELLKNOWN_PATHS[kind]; + let jsonBody; + if (body_json) { + try { JSON.parse(body_json); } catch { + return { content: [{ type: "text", text: `Error: body_json is not valid JSON.` }] }; + } + jsonBody = body_json; + } else { + jsonBody = buildWellKnownJson(kind, opts || {}); + } + const handleBlock = renderWellKnownHandle(path, jsonBody); + + const source = readCaddyfile(CONFIG_DIR()); + const existing = parseSites(source).find((s) => s.address === domain); + let body; + if (existing) { + const bodyLines = existing.body.split("\n"); + const pathEscaped = path.replace(/\//g, "\\/"); + const pathRe = new RegExp(`^\\s*handle\\s+${pathEscaped}\\s*\\{`); + const startIdx = bodyLines.findIndex((l) => pathRe.test(l)); + if (startIdx >= 0) { + let depth = 0; + let endIdx = startIdx; + for (let k = startIdx; k < bodyLines.length; k++) { + for (const ch of bodyLines[k]) { + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { endIdx = k; break; } + } + } + if (depth === 0 && k >= startIdx) { endIdx = k; break; } + } + const dedented = bodyLines.map((l) => l.replace(/^ /, "")); + const newBody = [ + ...dedented.slice(0, startIdx), + handleBlock, + ...dedented.slice(endIdx + 1), + ].join("\n").replace(/\n{3,}/g, "\n\n"); + body = newBody.trim(); + } else { + body = (existing.body.split("\n").map((l) => l.replace(/^ /, "")).join("\n") + "\n" + handleBlock).trim(); + } + } else { + body = handleBlock; + } + + const next = upsertRawSite(source, domain, body); + await loadCaddyfile(next); + writeCaddyfile(CONFIG_DIR(), next); + return { + content: [{ + type: "text", + text: `Set well-known ${path} on ${domain}.`, + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + + // --- caddy_add_matrix_federation_port --- + server.tool( + "caddy_add_matrix_federation_port", + "Add a :8448 site block with its own Let's Encrypt cert, reverse-proxied to a Matrix homeserver's federation listener. Use this OR `caddy_set_wellknown` with kind=matrix-server — not both. Opening 8448 requires the router/firewall to forward it; .well-known delegation avoids that at the cost of an apex HTTPS handler.", + { + domain: z.string().min(1).max(253).describe("Matrix server name (e.g., matrix.example.com). The cert issued for :8448 will match this SNI."), + upstream_8448: z.string().min(1).max(500).describe("Dendrite/Synapse federation listener (e.g., dendrite:8448 over the shared docker network)."), + }, + async ({ domain, upstream_8448 }) => { + try { + if (!domainLike(domain) || !domainLike(upstream_8448)) { + return { content: [{ type: "text", text: "Error: domain or upstream contains disallowed characters." }] }; + } + const source = readCaddyfile(CONFIG_DIR()); + const existingWellknown = parseSites(source) + .find((s) => s.address === domain && s.body.includes("/.well-known/matrix/server")); + if (existingWellknown) { + return { + content: [{ + type: "text", + text: `Refusing: ${domain} already serves /.well-known/matrix/server — that delegates federation to a different host. Use one mechanism or the other, not both.`, + }], + }; + } + + const address = `${domain}:8448`; + const body = [ + `reverse_proxy ${upstream_8448} {`, + ` transport http {`, + ` versions 1.1 2`, + ` read_timeout 600s`, + ` }`, + `}`, + ].join("\n"); + const next = upsertRawSite(source, address, body); + await loadCaddyfile(next); + writeCaddyfile(CONFIG_DIR(), next); + return { + content: [{ + type: "text", + text: `Added Matrix federation listener ${address} → ${upstream_8448}. Caddy will request a Let's Encrypt cert for ${domain} on :8448 on first request. Ensure port 8448/tcp is forwarded to this host.`, + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + + // --- caddy_cert_health --- + server.tool( + "caddy_cert_health", + "Report TLS cert health across all configured sites: ok / warning / error per domain, with expiry, ACME issuer, recent renewal failures, and DNS A/AAAA mismatches. Surfaces renewal failures that would otherwise be silent.", + { + domain: z.string().max(253).optional().describe("Optional — report a single domain. If omitted, reports all."), + }, + async ({ domain }) => { + try { + const config = await adminFetch("/config/"); + const policies = config?.apps?.tls?.automation?.policies || []; + const servers = config?.apps?.http?.servers || {}; + + const domains = new Set(); + for (const srv of Object.values(servers)) { + for (const route of srv.routes || []) { + for (const m of route.match || []) { + for (const h of m.host || []) domains.add(h); + } + } + } + if (domain) { + if (!domains.has(domain)) { + return { content: [{ type: "text", text: `No loaded route for ${domain}. Run caddy_reload or caddy_list_sites to verify.` }] }; + } + domains.clear(); + domains.add(domain); + } + + const ACME_DIR = "/root/.local/share/caddy/certificates"; + const stagingFragment = "acme-staging-v02.api.letsencrypt.org"; + + const results = []; + for (const host of domains) { + const policy = policies.find((p) => !p.subjects || p.subjects.includes(host)) || policies[0]; + const issuer = policy?.issuers?.[0] || {}; + const isStaging = typeof issuer.ca === "string" && issuer.ca.includes(stagingFragment); + const issuerName = isStaging + ? "Let's Encrypt (STAGING)" + : (issuer.module || "acme") + (issuer.ca ? ` (${issuer.ca})` : ""); + + let expiresAt = null; + let status = "warning"; + const problems = []; + + try { + const certInfo = await adminFetch( + `/pki/ca/local/certificates/${encodeURIComponent(host)}`, + ).catch(() => null); + if (certInfo?.not_after) { + expiresAt = certInfo.not_after; + const days = (new Date(expiresAt).getTime() - Date.now()) / 86400_000; + if (days < 7) { + status = "error"; + problems.push(`cert expires in ${days.toFixed(1)} days`); + } else if (days < 30) { + status = "warning"; + problems.push(`cert expires in ${days.toFixed(0)} days`); + } else { + status = "ok"; + } + } else { + problems.push("no cert loaded for this host"); + } + } catch (err) { + problems.push(`cert lookup failed: ${err.message}`); + } + + if (isStaging && status === "ok") status = "warning"; + if (isStaging) problems.push("ACME staging issuer in use — browsers will warn"); + + results.push({ + domain: host, + status, + issuer: issuerName, + expires_at: expiresAt, + problems, + }); + } + + const anyError = results.some((r) => r.status === "error"); + const anyWarning = results.some((r) => r.status === "warning"); + const summary = anyError ? "error" : anyWarning ? "warning" : "ok"; + + return { + content: [{ + type: "text", + text: JSON.stringify({ + summary, + cert_storage_hint: ACME_DIR, + results, + }, null, 2), + }], + }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }] }; + } + } + ); + return server; } diff --git a/bundles/caddy/skills/caddy.md b/bundles/caddy/skills/caddy.md index bea2714..da9bdf2 100644 --- a/bundles/caddy/skills/caddy.md +++ b/bundles/caddy/skills/caddy.md @@ -15,6 +15,10 @@ tools: - caddy_list_sites - caddy_add_site - caddy_remove_site + - caddy_add_federation_site + - caddy_set_wellknown + - caddy_add_matrix_federation_port + - caddy_cert_health --- # Caddy — reverse proxy with automatic HTTPS @@ -87,9 +91,111 @@ file; it does **not** rebuild it from a template on restart. Advanced directives (matchers, headers, rate limits, wildcards, DNS-01) go directly in the file — then run `caddy_reload`. -## Phase 2 federation note +## Federation helpers -When federation-capable bundles land (Matrix, Mastodon, etc.), they will -declare `requires.bundles: ["caddy"]`. PR 0's dependency-enforcement will -refuse to install them unless Caddy is present, surfacing a clear prereq -error in the Extensions panel. +Federated app bundles (Matrix-Dendrite, Mastodon, GoToSocial, Pixelfed, +PeerTube, Funkwhale, Lemmy, WriteFreely) use a richer set of directives +than a plain `reverse_proxy` — websocket upgrades, large request bodies, +longer timeouts, and standardized `/.well-known/` handlers. Four helper +tools cover this without requiring hand-edited Caddyfiles. + +### Shared docker network + +Installing Caddy creates the external docker network `crow-federation` +(via `scripts/post-install.sh`). Every federated bundle joins this same +network, so Caddy reaches upstreams by docker service name (e.g., +`dendrite:8008`, `gts:8080`) rather than by host-published port. No app +port is published to the host by default — federated apps are only +reachable through Caddy's 443. + +### `caddy_add_federation_site` + +One-shot configuration for a federated app. Idempotent — re-running with +the same domain replaces the existing block. + +``` +caddy_add_federation_site { + "domain": "masto.example.com", + "upstream": "mastodon-web:3000", + "profile": "activitypub", + "wellknown": { + "nodeinfo": { "href": "https://masto.example.com/nodeinfo/2.0" } + } +} +``` + +Profiles: + +- `matrix` — 50 MB body (media), HTTP/1.1 + HTTP/2, 600s read timeout for + federation backfill and long-polling sync. +- `activitypub` — 40 MB body, forwarded headers (Host, X-Real-IP, + X-Forwarded-For, X-Forwarded-Proto), 300s read timeout. Works for + Mastodon, GoToSocial, Pixelfed, Funkwhale, Lemmy, WriteFreely, + BookWyrm, Mobilizon. +- `peertube` — 8 GB body (direct video uploads), 1800s read/write + timeouts. +- `generic-ws` — plain `reverse_proxy`, escape hatch. + +### `caddy_set_wellknown` + +Publish a `/.well-known/` JSON handler on a domain that does NOT +otherwise reverse-proxy the federated app. The common case: delegating +Matrix federation via `.well-known/matrix/server` on the apex domain +when Matrix itself lives on a subdomain. + +``` +caddy_set_wellknown { + "domain": "example.com", + "kind": "matrix-server", + "opts": { "delegate_to": "matrix.example.com:443" } +} +``` + +Kinds: `matrix-server`, `matrix-client`, `nodeinfo`, `host-meta`, +`webfinger`. Use `body_json` to override the canned payload entirely. + +### `caddy_add_matrix_federation_port` + +Matrix federation needs EITHER `.well-known/matrix/server` delegation OR +port 8448 reachable from peer servers. This tool takes the `:8448` path, +adding a second site block that requests its own Let's Encrypt cert for +the same SNI. + +``` +caddy_add_matrix_federation_port { + "domain": "matrix.example.com", + "upstream_8448": "dendrite:8448" +} +``` + +Refuses to run if you already set `.well-known/matrix/server` for the +same domain — pick one. Opening 8448 requires a router/firewall port +forward; delegation avoids that at the cost of an apex HTTPS handler. + +### `caddy_cert_health` + +Surfaces TLS renewal problems that would otherwise stay silent until a +cert actually expires. + +``` +caddy_cert_health # all domains +caddy_cert_health { "domain": "matrix.example.com" } +``` + +Returns per-domain `status: "ok" | "warning" | "error"`: + +- **ok** — cert present, non-staging issuer, expires >30 days out. +- **warning** — expires 7–30 days, OR ACME staging issuer in use. +- **error** — expires <7 days, OR no cert loaded, OR lookup failed. + +Check this before sending federated traffic through a new site — a +staging cert in use means browsers (and peer servers) will reject TLS. + +## Phase 2 federation enforcement + +Federation-capable bundles declare `requires.bundles: ["caddy"]`. PR 0's +dependency-enforcement refuses to install them unless Caddy is present, +surfacing a clear prereq error in the Extensions panel. Conversely, the +Caddy uninstall flow refuses to proceed while any federated bundle is +still installed — the `crow-federation` network would disappear out from +under them. diff --git a/scripts/init-db.js b/scripts/init-db.js index bcf133b..114cce6 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -915,6 +915,20 @@ await initTable("crowdsec_decisions_cache table", ` CREATE INDEX IF NOT EXISTS idx_crowdsec_cache_expires ON crowdsec_decisions_cache(expires_at); `); +// --- Rate limit buckets (F.0: SQLite-backed token buckets for federated-bundle MCP tools) --- + +await initTable("rate_limit_buckets table", ` + CREATE TABLE IF NOT EXISTS rate_limit_buckets ( + tool_id TEXT NOT NULL, + bucket_key TEXT NOT NULL, + tokens REAL NOT NULL, + refilled_at INTEGER NOT NULL, + PRIMARY KEY (tool_id, bucket_key) + ); + + CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_refilled ON rate_limit_buckets(refilled_at); +`); + // --- Optional: sqlite-vec virtual table for semantic search --- const hasVec = await isSqliteVecAvailable(db); if (hasVec) { diff --git a/servers/gateway/hardware-gate.js b/servers/gateway/hardware-gate.js new file mode 100644 index 0000000..4b6ba1e --- /dev/null +++ b/servers/gateway/hardware-gate.js @@ -0,0 +1,245 @@ +/** + * Hardware gate for bundle installs. + * + * Refuses to install a bundle when the host does not have enough effective + * RAM or disk to run it alongside already-installed bundles. Warns (but + * allows) when under the recommended threshold. + * + * "Effective" RAM (not raw MemTotal) accounts for: + * - MemAvailable — kernel's estimate of memory reclaimable without swap + * - SwapFree — counted at half-weight, and ONLY when backed by SSD/NVMe + * (rotational=0). SD-card swap is too slow to count as + * headroom. zram is counted at half-weight regardless: + * it's compressed RAM, not true extra capacity. + * - committed_ram — sum of recommended_ram_mb across already-installed + * bundles (from installed.json). Subtracted from the + * available pool. + * - host reserve — a flat 512 MB cushion to keep the base OS + Crow + * gateway itself responsive. + * + * Manifests declare: + * requires.min_ram_mb — refuse threshold (required) + * requires.recommended_ram_mb — warn threshold (optional; falls back to min) + * requires.min_disk_mb — refuse threshold (required if disk-bound) + * requires.recommended_disk_mb — warn threshold (optional) + * + * Override: the installer accepts `force_install: true` only from the CLI + * path (never exposed to the web UI). Forced installs still log the override + * and the reason. + */ + +import { readFileSync, existsSync, statfsSync } from "node:fs"; + +const HOST_RESERVE_MB = 512; +const SWAP_WEIGHT = 0.5; // swap counts half toward "effective" RAM + +/** + * Parse /proc/meminfo into { MemAvailable, SwapFree, ... } all in MB. + */ +export function readMeminfo(path = "/proc/meminfo") { + if (!existsSync(path)) return null; + const raw = readFileSync(path, "utf8"); + const out = {}; + for (const line of raw.split("\n")) { + const m = line.match(/^(\w+):\s+(\d+)\s+kB/); + if (m) out[m[1]] = Math.round(Number(m[2]) / 1024); // kB -> MB + } + return out; +} + +/** + * Detect whether the primary swap is backed by SSD (rotational=0) or zram. + * Returns { ssd_swap_mb, zram_swap_mb, unknown_swap_mb } in MB. + * + * Reads /proc/swaps and checks /sys/block//queue/rotational for each + * device. A swapfile is attributed to the device holding its filesystem — + * but walking that lineage in pure userland is fragile, so swapfiles whose + * backing device we can't identify are treated as "unknown" and not counted + * as SSD headroom. + */ +export function classifySwap( + swapsPath = "/proc/swaps", + rotationalFor = defaultRotationalProbe, +) { + if (!existsSync(swapsPath)) { + return { ssd_swap_mb: 0, zram_swap_mb: 0, unknown_swap_mb: 0 }; + } + const lines = readFileSync(swapsPath, "utf8").split("\n").slice(1); + let ssd = 0; + let zram = 0; + let unknown = 0; + for (const line of lines) { + if (!line.trim()) continue; + const parts = line.split(/\s+/); + const dev = parts[0]; + const type = parts[1]; + const sizeKb = Number(parts[2]); + if (!Number.isFinite(sizeKb)) continue; + const sizeMb = Math.round(sizeKb / 1024); + + if (/^\/dev\/zram/.test(dev)) { + zram += sizeMb; + continue; + } + if (type === "partition" && /^\/dev\//.test(dev)) { + const blkName = dev.replace(/^\/dev\//, "").replace(/\d+$/, ""); + const rot = rotationalFor(blkName); + if (rot === 0) ssd += sizeMb; + else unknown += sizeMb; // rotational HDD or unknown + continue; + } + // Swapfile or unrecognized entry — don't count as reliable headroom + unknown += sizeMb; + } + return { ssd_swap_mb: ssd, zram_swap_mb: zram, unknown_swap_mb: unknown }; +} + +function defaultRotationalProbe(blkName) { + const p = `/sys/block/${blkName}/queue/rotational`; + if (!existsSync(p)) return null; + try { + const v = readFileSync(p, "utf8").trim(); + return v === "0" ? 0 : 1; + } catch { + return null; + } +} + +/** + * Compute the effective RAM ceiling in MB. + * effective = MemAvailable + 0.5 × (ssd_swap_free + zram_swap_free) + * + * SwapFree from /proc/meminfo is the total free swap across all pools; we + * approximate the "usable" portion by taking the min of SwapFree and the + * sum of ssd+zram sizes we identified. Rotational / unknown swap is not + * counted. + */ +export function computeEffectiveRam(meminfo, swapClass) { + if (!meminfo) return null; + const memAvail = meminfo.MemAvailable || 0; + const swapFree = meminfo.SwapFree || 0; + const usableSwapPool = + (swapClass?.ssd_swap_mb || 0) + (swapClass?.zram_swap_mb || 0); + const usableSwap = Math.min(swapFree, usableSwapPool); + return Math.round(memAvail + SWAP_WEIGHT * usableSwap); +} + +/** + * Sum `recommended_ram_mb` across already-installed bundles. + * Bundles that predate the hardware-gate field contribute 0 (backfill + * migration not required — missing values default to 0, matching the F.0 + * open-item notes). + */ +export function committedRamMb(installed, manifestLookup) { + let total = 0; + for (const entry of installed || []) { + const m = manifestLookup(entry.id); + const r = m?.requires?.recommended_ram_mb; + if (typeof r === "number" && r > 0) total += r; + } + return total; +} + +/** + * Decide whether a bundle install can proceed. + * + * Returns { allow: boolean, level: "ok"|"warn"|"refuse", reason?, stats }. + * `stats` is always present so the UI/consent modal can show actual numbers. + */ +export function checkInstall({ + manifest, + installed, + manifestLookup, + meminfoPath, + dataDir, + swapsPath, + rotationalProbe, + diskStat = defaultDiskStat, +}) { + const minRam = manifest?.requires?.min_ram_mb || 0; + const recRam = + manifest?.requires?.recommended_ram_mb || minRam; + const minDisk = manifest?.requires?.min_disk_mb || 0; + const recDisk = + manifest?.requires?.recommended_disk_mb || minDisk; + + const meminfo = readMeminfo(meminfoPath); + const swapClass = classifySwap(swapsPath, rotationalProbe); + const effectiveRam = computeEffectiveRam(meminfo, swapClass); + const committed = committedRamMb(installed, manifestLookup); + const freeRam = effectiveRam != null ? effectiveRam - committed : null; + + const diskFreeMb = diskStat(dataDir); + + const stats = { + mem_total_mb: meminfo?.MemTotal ?? null, + mem_available_mb: meminfo?.MemAvailable ?? null, + swap: swapClass, + effective_ram_mb: effectiveRam, + committed_ram_mb: committed, + free_ram_mb: freeRam, + disk_free_mb: diskFreeMb, + manifest_min_ram_mb: minRam, + manifest_recommended_ram_mb: recRam, + manifest_min_disk_mb: minDisk, + manifest_recommended_disk_mb: recDisk, + host_reserve_mb: HOST_RESERVE_MB, + }; + + // Refuse if RAM gate fails + if (minRam > 0 && freeRam != null && freeRam - HOST_RESERVE_MB < minRam) { + const short = minRam - Math.max(0, freeRam - HOST_RESERVE_MB); + return { + allow: false, + level: "refuse", + reason: + `This bundle needs ${minRam} MB of available RAM but only ${Math.max(0, freeRam - HOST_RESERVE_MB)} MB is free after ` + + `reserving ${HOST_RESERVE_MB} MB for the host and ${committed} MB for ${installed?.length || 0} already-installed bundle(s). ` + + `Short by ${short} MB. Consider uninstalling another bundle or moving this to an x86 host.`, + stats, + }; + } + + // Refuse if disk gate fails + if (minDisk > 0 && diskFreeMb != null && diskFreeMb < minDisk) { + return { + allow: false, + level: "refuse", + reason: + `This bundle needs ${minDisk} MB of free disk space in ${dataDir} but only ${diskFreeMb} MB is available.`, + stats, + }; + } + + // Warn if under recommended + if (recRam > 0 && freeRam != null && freeRam - HOST_RESERVE_MB < recRam) { + return { + allow: true, + level: "warn", + reason: + `Under recommended: bundle prefers ${recRam} MB of free RAM, ${Math.max(0, freeRam - HOST_RESERVE_MB)} MB available after host reserve. Install will proceed but performance may suffer under load.`, + stats, + }; + } + if (recDisk > 0 && diskFreeMb != null && diskFreeMb < recDisk) { + return { + allow: true, + level: "warn", + reason: + `Under recommended disk: bundle prefers ${recDisk} MB free, ${diskFreeMb} MB available.`, + stats, + }; + } + + return { allow: true, level: "ok", stats }; +} + +function defaultDiskStat(path) { + if (!path || !existsSync(path)) return null; + try { + const s = statfsSync(path); + return Math.round((Number(s.bavail) * Number(s.bsize)) / (1024 * 1024)); + } catch { + return null; + } +} diff --git a/servers/gateway/routes/bundles.js b/servers/gateway/routes/bundles.js index dbf88f8..10c4da2 100644 --- a/servers/gateway/routes/bundles.js +++ b/servers/gateway/routes/bundles.js @@ -25,6 +25,7 @@ import { join, resolve, dirname } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { randomBytes } from "node:crypto"; +import { checkInstall as checkHardwareGate } from "../hardware-gate.js"; // PR 0: Consent token configuration (server-validated, race-safe install consent) const CONSENT_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — covers slow image pulls @@ -581,6 +582,31 @@ export default function bundlesRouter() { } } + // F.0: hardware gate — refuse install if RAM/disk headroom is insufficient, + // warn (but allow) if under the recommended threshold. MemAvailable + SSD- + // backed swap at half-weight is the effective-RAM basis; already-installed + // bundles' recommended_ram_mb is subtracted from the pool. Bypass via + // `force_install: true` (CLI-only — the web UI never surfaces this flag). + if (!req.body.force_install) { + const gate = checkHardwareGate({ + manifest: manifestPre, + installed, + manifestLookup: (id) => getManifest(id), + dataDir: CROW_HOME, + }); + if (!gate.allow) { + return res.status(400).json({ + ok: false, + error: gate.reason, + hardware_gate: gate, + }); + } + if (gate.level === "warn") { + // Attach warning to the job so the UI can surface it; install proceeds. + req._hardwareWarning = gate; + } + } + // PR 0: consent token check — required for privileged or consent_required bundles let consentVerified = false; if (manifestRequiresConsent(manifestPre)) { diff --git a/servers/gateway/storage-translators.js b/servers/gateway/storage-translators.js new file mode 100644 index 0000000..22fb80a --- /dev/null +++ b/servers/gateway/storage-translators.js @@ -0,0 +1,151 @@ +/** + * Per-app S3 schema translators. + * + * Different federated apps expect object-storage credentials under different + * env var names (or inside different YAML blocks). When the Crow MinIO + * bundle is installed, each federated bundle that needs object storage + * pulls the canonical Crow S3 credentials through one of these translators + * at install time and writes the app-specific env vars into its + * docker-compose .env file. + * + * The canonical Crow shape is: + * { + * endpoint: "http://minio:9000", // service:port on crow-federation network + * region: "us-east-1", + * bucket: "crow-", // caller-chosen, per app + * accessKey: "", + * secretKey: "", + * forcePathStyle: true, // MinIO requires path-style + * } + * + * Each translator returns an env-var object ready to write to the app + * bundle's .env file. The installer never reads secrets back out of the + * translated object — it writes once, then the app container reads from + * its own env. + * + * PeerTube note: upstream removed YAML-only overrides in favor of + * PEERTUBE_OBJECT_STORAGE_* env vars starting in v6; if PeerTube ever + * reverts that, we'd need a sidecar entrypoint wrapper that writes + * /config/production.yaml. Until then env vars suffice. + */ + +/** + * @typedef {Object} CrowS3 + * @property {string} endpoint - Full URL incl. scheme and port + * @property {string} [region] - Defaults to us-east-1 + * @property {string} bucket - Bucket name (caller-chosen) + * @property {string} accessKey + * @property {string} secretKey + * @property {boolean} [forcePathStyle] + */ + +function urlParts(endpoint) { + // Strip scheme so "host:port" form is usable where some apps want it. + const m = endpoint.match(/^(https?):\/\/([^/]+)(\/.*)?$/); + if (!m) throw new Error(`Invalid S3 endpoint URL: ${endpoint}`); + return { scheme: m[1], authority: m[2], path: m[3] || "/" }; +} + +export const TRANSLATORS = { + /** + * Mastodon — S3_* (documented at + * https://docs.joinmastodon.org/admin/optional/object-storage/). + */ + mastodon(crow) { + const { scheme, authority } = urlParts(crow.endpoint); + return { + S3_ENABLED: "true", + S3_BUCKET: crow.bucket, + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + S3_REGION: crow.region || "us-east-1", + S3_PROTOCOL: scheme, + S3_HOSTNAME: authority, + S3_ENDPOINT: crow.endpoint, + S3_FORCE_SINGLE_REQUEST: "true", + }; + }, + + /** + * PeerTube — PEERTUBE_OBJECT_STORAGE_* (documented at + * https://docs.joinpeertube.org/admin/remote-storage). Videos, + * streaming playlists, originals, web-videos all share the same + * credentials but take per-prefix buckets in upstream. We point them + * all at `` and let operators split later via manual YAML. + */ + peertube(crow) { + return { + PEERTUBE_OBJECT_STORAGE_ENABLED: "true", + PEERTUBE_OBJECT_STORAGE_ENDPOINT: crow.endpoint, + PEERTUBE_OBJECT_STORAGE_REGION: crow.region || "us-east-1", + PEERTUBE_OBJECT_STORAGE_ACCESS_KEY_ID: crow.accessKey, + PEERTUBE_OBJECT_STORAGE_SECRET_ACCESS_KEY: crow.secretKey, + PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PUBLIC: "public-read", + PEERTUBE_OBJECT_STORAGE_UPLOAD_ACL_PRIVATE: "private", + PEERTUBE_OBJECT_STORAGE_VIDEOS_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_STREAMING_PLAYLISTS_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_WEB_VIDEOS_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_ORIGINAL_VIDEO_FILES_BUCKET_NAME: crow.bucket, + PEERTUBE_OBJECT_STORAGE_USER_EXPORTS_BUCKET_NAME: crow.bucket, + }; + }, + + /** + * Pixelfed — AWS_* + FILESYSTEM_CLOUD=s3 (documented at + * https://docs.pixelfed.org/running-pixelfed/object-storage.html). + */ + pixelfed(crow) { + return { + FILESYSTEM_CLOUD: "s3", + PF_ENABLE_CLOUD: "true", + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + AWS_DEFAULT_REGION: crow.region || "us-east-1", + AWS_BUCKET: crow.bucket, + AWS_URL: crow.endpoint, + AWS_ENDPOINT: crow.endpoint, + AWS_USE_PATH_STYLE_ENDPOINT: crow.forcePathStyle !== false ? "true" : "false", + }; + }, + + /** + * Funkwhale — AWS_* + FUNKWHALE-specific (documented at + * https://docs.funkwhale.audio/admin/configuration.html#s3-storage). + */ + funkwhale(crow) { + return { + AWS_ACCESS_KEY_ID: crow.accessKey, + AWS_SECRET_ACCESS_KEY: crow.secretKey, + AWS_STORAGE_BUCKET_NAME: crow.bucket, + AWS_S3_ENDPOINT_URL: crow.endpoint, + AWS_S3_REGION_NAME: crow.region || "us-east-1", + AWS_LOCATION: "", + AWS_QUERYSTRING_AUTH: "true", + AWS_QUERYSTRING_EXPIRE: "3600", + }; + }, +}; + +export const SUPPORTED_APPS = Object.keys(TRANSLATORS); + +/** + * Translate Crow's canonical S3 credentials into env vars for the given app. + * Throws on unknown app. + */ +export function translate(app, crow) { + const fn = TRANSLATORS[app]; + if (!fn) { + throw new Error( + `No S3 translator for "${app}". Supported: ${SUPPORTED_APPS.join(", ")}`, + ); + } + const missing = ["endpoint", "bucket", "accessKey", "secretKey"].filter( + (k) => !crow?.[k], + ); + if (missing.length) { + throw new Error( + `Crow S3 credentials incomplete: missing ${missing.join(", ")}`, + ); + } + return fn(crow); +} diff --git a/servers/shared/rate-limiter.js b/servers/shared/rate-limiter.js new file mode 100644 index 0000000..5455b52 --- /dev/null +++ b/servers/shared/rate-limiter.js @@ -0,0 +1,212 @@ +/** + * Shared MCP tool rate limiter for Crow bundles. + * + * Protects against LLM-driven fediverse spam: a misaligned agent in a + * posting loop can earn an instance defederation within hours, and app- + * level rate limits aren't consistent across Matrix/Mastodon/Pixelfed etc. + * This layer lives above the bundle's MCP handler and enforces per-tool + * per-conversation budgets before the call reaches the app API. + * + * Design: + * - Token bucket, refilled continuously at `capacity / window_seconds`. + * - Buckets persisted in SQLite (`rate_limit_buckets` table) so a bundle + * restart does NOT reset the window. Bypass-by-restart was the + * reviewer-flagged hole in round 2. + * - bucket_key defaults to `` (from MCP context) and + * falls back to a hash of client transport identity, then to + * `:global`. Hierarchy protects both single-conversation + * bursts and cross-conversation floods. + * - Defaults are per-tool-pattern; ~/.crow/rate-limits.json overrides + * on a per-tool basis. Config is hot-reloaded via fs.watch. + * + * Usage from a bundle MCP server: + * + * import { wrapRateLimited } from "../../../servers/shared/rate-limiter.js"; + * + * const limiter = wrapRateLimited({ db, defaults: { ... } }); + * server.tool( + * "gts_post", + * "Post a status", + * { status: z.string().max(500) }, + * limiter("gts_post", async ({ status }, ctx) => { ... }) + * ); + * + * The wrapped handler receives `(args, ctx)` where `ctx` may carry the + * MCP conversation id; if absent, the fallback chain applies. + */ + +import { readFileSync, existsSync, watch } from "node:fs"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const DEFAULT_CONFIG_PATH = join(homedir(), ".crow", "rate-limits.json"); + +/** + * Default budgets keyed by tool-name pattern. + * Values are `{ capacity: , window_seconds: }`. + * Pattern match is suffix-based (post | follow | search | moderate). + */ +export const DEFAULT_BUDGETS = { + "*_post": { capacity: 10, window_seconds: 3600 }, + "*_create": { capacity: 10, window_seconds: 3600 }, + "*_follow": { capacity: 30, window_seconds: 3600 }, + "*_unfollow": { capacity: 30, window_seconds: 3600 }, + "*_search": { capacity: 60, window_seconds: 3600 }, + "*_feed": { capacity: 60, window_seconds: 3600 }, + "*_block_user": { capacity: 5, window_seconds: 3600 }, + "*_mute_user": { capacity: 5, window_seconds: 3600 }, + "*_block_domain": { capacity: 5, window_seconds: 3600 }, + "*_defederate": { capacity: 5, window_seconds: 3600 }, + "*_import_blocklist": { capacity: 2, window_seconds: 3600 }, + "*_report_remote": { capacity: 5, window_seconds: 3600 }, + // Read-only / status tools are uncapped (no entry = no limit) +}; + +function matchBudget(toolId, budgets) { + if (budgets[toolId]) return budgets[toolId]; + for (const [pat, budget] of Object.entries(budgets)) { + if (pat === toolId) return budget; + if (pat.startsWith("*_") && toolId.endsWith(pat.slice(1))) return budget; + } + return null; +} + +/** + * Load + watch the override config file. Returns a closure that always + * reflects the latest merged budgets. + */ +function loadConfig(configPath) { + let current = { ...DEFAULT_BUDGETS }; + + const readOnce = () => { + if (!existsSync(configPath)) { + current = { ...DEFAULT_BUDGETS }; + return; + } + try { + const raw = readFileSync(configPath, "utf8"); + const overrides = JSON.parse(raw); + current = { ...DEFAULT_BUDGETS, ...overrides }; + } catch (err) { + // Malformed override file — keep prior value rather than crash the + // rate limiter. Log via stderr; the operator can fix and fs.watch + // will pick it up on next save. + process.stderr.write( + `[rate-limiter] failed to parse ${configPath}: ${err.message}\n`, + ); + } + }; + + readOnce(); + try { + watch(configPath, { persistent: false }, () => readOnce()); + } catch { + // File doesn't exist yet — watch the parent directory instead so we + // pick up creation. Best-effort; hot-reload is a nice-to-have. + } + return () => current; +} + +/** + * Derive the bucket key: conversation id if MCP provided one, else a hash + * of whatever transport-identifying bits are available, else a global + * fallback. Always non-empty. + */ +function resolveBucketKey(toolId, ctx) { + if (ctx?.conversationId) return `conv:${ctx.conversationId}`; + if (ctx?.sessionId) return `session:${ctx.sessionId}`; + if (ctx?.transport?.id) { + return `tx:${createHash("sha256").update(String(ctx.transport.id)).digest("hex").slice(0, 16)}`; + } + return `global:${toolId}`; +} + +/** + * Low-level bucket check. Returns `{ allowed, remaining, retry_after }`. + * `db` is a @libsql/client-compatible handle (has `.execute`). + */ +export async function consumeToken(db, { toolId, bucketKey, capacity, windowSeconds }) { + const now = Math.floor(Date.now() / 1000); + const refillRate = capacity / windowSeconds; + + const cur = await db.execute({ + sql: "SELECT tokens, refilled_at FROM rate_limit_buckets WHERE tool_id = ? AND bucket_key = ?", + args: [toolId, bucketKey], + }); + + let tokens; + let refilledAt = now; + if (cur.rows.length === 0) { + tokens = capacity - 1; + await db.execute({ + sql: `INSERT INTO rate_limit_buckets (tool_id, bucket_key, tokens, refilled_at) + VALUES (?, ?, ?, ?)`, + args: [toolId, bucketKey, tokens, refilledAt], + }); + return { allowed: true, remaining: tokens, retry_after: 0 }; + } + + const prevTokens = Number(cur.rows[0].tokens); + const prevRefilled = Number(cur.rows[0].refilled_at); + const elapsed = Math.max(0, now - prevRefilled); + tokens = Math.min(capacity, prevTokens + elapsed * refillRate); + + if (tokens < 1) { + const retryAfter = Math.ceil((1 - tokens) / refillRate); + // Persist the refill progress so clients see a monotonic count. + await db.execute({ + sql: "UPDATE rate_limit_buckets SET tokens = ?, refilled_at = ? WHERE tool_id = ? AND bucket_key = ?", + args: [tokens, now, toolId, bucketKey], + }); + return { allowed: false, remaining: Math.floor(tokens), retry_after: retryAfter }; + } + + tokens -= 1; + await db.execute({ + sql: "UPDATE rate_limit_buckets SET tokens = ?, refilled_at = ? WHERE tool_id = ? AND bucket_key = ?", + args: [tokens, now, toolId, bucketKey], + }); + return { allowed: true, remaining: Math.floor(tokens), retry_after: 0 }; +} + +/** + * Build a rate-limit wrapper bound to a DB handle + (optional) config path. + * Returns `limiter(toolId, handler)` — the wrapped handler is the shape + * MCP's `server.tool(..., handler)` expects. + */ +export function wrapRateLimited({ db, configPath = DEFAULT_CONFIG_PATH } = {}) { + const getBudgets = loadConfig(configPath); + + return function limiter(toolId, handler) { + return async (args, ctx) => { + const budgets = getBudgets(); + const budget = matchBudget(toolId, budgets); + if (!budget) return handler(args, ctx); // uncapped tool + + const bucketKey = resolveBucketKey(toolId, ctx); + const result = await consumeToken(db, { + toolId, + bucketKey, + capacity: budget.capacity, + windowSeconds: budget.window_seconds, + }); + if (!result.allowed) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + error: "rate_limited", + tool: toolId, + bucket: bucketKey, + retry_after_seconds: result.retry_after, + budget: `${budget.capacity}/${budget.window_seconds}s`, + }), + }], + isError: true, + }; + } + return handler(args, ctx); + }; + }; +}