Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ bundles/iptv/ → IPTV channel manager (MCP server, 6 tools, M3
bundles/kodi/ → Kodi remote control (MCP server, 6 tools, JSON-RPC)
bundles/trilium/ → TriliumNext knowledge base (Docker + MCP server, 11 tools, ETAPI)
bundles/knowledge-base/ → Multilingual knowledge base (MCP server, 10 tools, LAN discovery, WCAG 2.1 AA)
bundles/maker-lab/ → STEM education companion for kids (MCP server, 21 tools, age-banded personas, classroom-capable). Phase 1 scaffold; see bundles/maker-lab/PHASE-0-REPORT.md
android/ → Android WebView shell app (Crow's Nest mobile client)
servers/gateway/public/ → PWA assets (manifest.json, service worker, icons)
servers/gateway/push/ → Web Push notification infrastructure (VAPID)
Expand Down Expand Up @@ -460,6 +461,7 @@ Add-on skills (activated when corresponding add-on is installed):
- `kodi.md` — Kodi remote control: JSON-RPC playback, library browsing
- `trilium.md` — TriliumNext knowledge base: note search, creation, web clipping, organization
- `knowledge-base.md` — Multilingual knowledge base: create, edit, publish, search, verify resources, share articles, LAN discovery
- `maker-lab.md` — STEM education companion for kids: scaffolded AI tutor, hint-ladder pedagogy, age-banded personas (kid/tween/adult), solo/family/classroom modes, guest sidecar
- `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS
- `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download
- `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read
Expand Down
10 changes: 10 additions & 0 deletions bundles/caddy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
80 changes: 80 additions & 0 deletions bundles/caddy/panel/caddy.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export default {
<div id="cd-status" class="cd-status"><div class="np-loading">Loading status…</div></div>
</div>

<div class="cd-section">
<h3>Certificate Health</h3>
<div id="cd-certs" class="cd-certs"><div class="np-loading">Loading…</div></div>
</div>

<div class="cd-section">
<h3>Sites</h3>
<div id="cd-sites" class="cd-sites"><div class="np-loading">Loading…</div></div>
Expand Down Expand Up @@ -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();
`;
}

Expand Down Expand Up @@ -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; }
`;
}
63 changes: 63 additions & 0 deletions bundles/caddy/panel/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
21 changes: 21 additions & 0 deletions bundles/caddy/scripts/post-install.sh
Original file line number Diff line number Diff line change
@@ -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"
30 changes: 30 additions & 0 deletions bundles/caddy/server/caddyfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <upstream>` 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.
Expand Down
Loading
Loading