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 {
Sites
@@ -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);
+ };
+ };
+}