mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(auth): add revocable API tokens (bot/service auth) Add long-lived, revocable API tokens so headless clients (the introspection MCP tool, bots, CI) can authenticate without a password. Presented as "Authorization: Bearer <token>", a token authenticates as a specific user, inheriting that user's role and permissions. - migration 018 + dedicated api_tokens table (kept separate from sessions so bulk logout and the per-user session cap never touch these); only the SHA-256 hash is stored, raw token shown once at creation - auth.ResolveTokenHash: one shared bearer resolver that both AuthMiddleware and adminAuthMiddleware now call. Sessions are matched first so existing login behavior is unchanged; API tokens are a fallback only on session miss. A DB outage is returned wrapped, never mistaken for a bad token. - `server token create|list|revoke` CLI: mints directly against the DB with no HTTP and no login — the password-free bootstrap path - tests: resolver (8 cases incl. outage-not-fallthrough), db queries (6), api middleware integration (valid + revoked token) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(tools): add owncord-introspect MCP server A local MCP dev tool that lets Claude Code introspect a running OwnCord instance: read its logs, query any REST endpoint, and tail the desktop client's log file. It is a thin wrapper over the existing API plus the client log — no new product surface. - tools/mcp-introspect/index.mjs (Node/ESM, one dep: @modelcontextprotocol/sdk) exposes api_request (full read-write passthrough), server_logs (admin SSE ring-buffer stream), client_logs (reads the desktop log file) - authenticates with an API token (OWNCORD_API_TOKEN); pins the self-signed cert and skips hostname checks (the cert has no SAN) - registered in .mcp.json (secret-free ${OWNCORD_API_TOKEN}) - un-ignore tools/mcp-introspect/ so this shared dev tool is committed, while tools/livekit-server.exe and node_modules stay ignored - docs/mcp-introspect.md: how it works, tool reference, setup, troubleshooting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(dependencies): update and add various crate versions in Cargo.lock * feat(admin): manage API tokens from the admin panel Add Owner-gated HTTP endpoints and a UI card to create, list, and revoke API tokens from the web admin panel. Previously only the `server token` CLI could manage them, which requires shell access to the host. - POST|GET|DELETE /admin/api/tokens in admin/handlers_tokens.go, wired in admin/api.go. All three are Owner-only (ownerOnlyMiddleware, like backups/updates): an HTTP token-mint endpoint is a network-reachable credential-minting surface, and API tokens deliberately survive password change + bulk logout, so a hijacked admin session must not mint one. - Reuses the same db.*APIToken calls as the CLI; create sources the actor from request context (audits who clicked, not the bound user); the raw token is returned once in the 201 body, never stored. - Add json tags to db.APITokenListItem for snake_case wire consistency. - Admin panel: "API Tokens" nav item + create modal, show-once reveal, revoke confirm in admin/static/index.html. - Tests: 7 in admin/api_test.go (+api_tokens table in the in-memory schema). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: modernize to Go 1.26 idioms + enable modernize linter Apply `golangci-lint modernize` autofixes across the server and enable the linter in .golangci.yml so these stop re-accumulating (they built up only because modernize was never in the config). Production code: slices.Contains for hand-rolled membership loops (api router, ws origin, db/account, plugin manifest); strings.SplitSeq for allocation-free line/segment iteration (db/migrate, updater, livekit_proxy); strings.Cut (config); fmt.Appendf (dm_handler); min() (event_pruner); any (ws client). Tests: range-over-int, t.Context(), WaitGroup.Go, slices.Sort, maps.Copy, new(expr), interface{}->any. - plugin/manifest.go parent-traversal check applied by hand: modernize skipped it (two conflicting rewrites); used the slices.Contains form. - Removed the now-dead ptr() test helper after newexpr inlined its callers. - Dropped dangling sort imports left by the sort.Slice->slices.Sort rewrite. No behavior change. All four tag variants build, full test suite is green, and golangci-lint (with modernize enabled) reports 0 issues. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// OwnCord introspection MCP server (dev tool for Claude Code).
|
|
//
|
|
// Exposes three tools over stdio:
|
|
// api_request — full read-write passthrough to any OwnCord REST endpoint
|
|
// server_logs — the admin ring-buffer log stream (SSE ticket -> stream)
|
|
// client_logs — tail the desktop client's on-disk log file
|
|
//
|
|
// Auth: a long-lived OwnCord API token in OWNCORD_API_TOKEN, sent as a bearer
|
|
// header (mint one with `server token create --label mcp-introspect`).
|
|
// TLS: pins the server's self-signed cert (Server/data/cert.pem). The cert has
|
|
// no SAN, so hostname verification is intentionally skipped — identity is proven
|
|
// by the pin.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import https from "node:https";
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import { z } from "zod";
|
|
|
|
// tools/mcp-introspect -> repo root
|
|
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
|
const TOKEN = process.env.OWNCORD_API_TOKEN || "";
|
|
const CERT_PATH = process.env.OWNCORD_CERT_PATH || join(REPO_ROOT, "Server", "data", "cert.pem");
|
|
const CLIENT_LOG =
|
|
process.env.OWNCORD_CLIENT_LOG ||
|
|
join(process.env.LOCALAPPDATA || "", "com.owncord.client", "logs", "owncord-client.log");
|
|
|
|
// Base URL: explicit override, else https://127.0.0.1:<server.port from config.yaml>.
|
|
function readServerPort() {
|
|
try {
|
|
const text = readFileSync(join(REPO_ROOT, "Server", "config.yaml"), "utf8");
|
|
// Scope to the top-level `server:` block so we don't grab a voice/livekit port.
|
|
const m = text.match(/^server:\s*$[\s\S]*?^\s+port:\s*(\d+)/m);
|
|
if (m) return Number(m[1]);
|
|
} catch {
|
|
/* fall through to default */
|
|
}
|
|
return 8443;
|
|
}
|
|
const BASE_URL = process.env.OWNCORD_BASE_URL || `https://127.0.0.1:${readServerPort()}`;
|
|
|
|
// Lazily built so client_logs works even when the cert/server is absent. Only
|
|
// https bases need the pinned agent; an http override (rare) uses none.
|
|
let _agent;
|
|
function httpsAgent() {
|
|
if (!BASE_URL.startsWith("https:")) return undefined;
|
|
if (_agent) return _agent;
|
|
let ca;
|
|
try {
|
|
ca = readFileSync(CERT_PATH);
|
|
} catch {
|
|
throw new Error(
|
|
`OwnCord cert not found at ${CERT_PATH}. Start the server once to generate it, ` +
|
|
`or set OWNCORD_CERT_PATH (or OWNCORD_BASE_URL for a non-TLS endpoint).`,
|
|
);
|
|
}
|
|
// Pin the exact self-signed cert; skip hostname check (the cert has no SAN).
|
|
_agent = new https.Agent({ ca, checkServerIdentity: () => undefined });
|
|
return _agent;
|
|
}
|
|
|
|
function requireToken() {
|
|
if (!TOKEN) throw new Error("OWNCORD_API_TOKEN is not set — mint one with `server token create`.");
|
|
}
|
|
|
|
// One request helper backs api_request and is reused by the log flow. Never
|
|
// sends an Origin header: the server treats a missing Origin as a non-browser
|
|
// client and skips CSRF/Origin checks.
|
|
function request(method, path, { query, body, headers } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(/^https?:/.test(path) ? path : BASE_URL + path);
|
|
if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, String(v));
|
|
const payload = body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body);
|
|
const h = {
|
|
Authorization: `Bearer ${TOKEN}`,
|
|
...(payload !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
...(headers || {}),
|
|
};
|
|
const req = https.request(url, { method, agent: httpsAgent(), headers: h }, (res) => {
|
|
let data = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (c) => (data += c));
|
|
res.on("end", () => {
|
|
let parsed = data;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch {
|
|
/* leave as raw text */
|
|
}
|
|
resolve({ status: res.statusCode, headers: res.headers, body: parsed });
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
if (payload !== undefined) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Fetch the server ring-buffer logs. The stream is SSE guarded by a single-use
|
|
// ticket (EventSource can't send auth headers). Read the backfill burst, then
|
|
// optionally keep reading for follow_ms, filter client-side, and return.
|
|
async function collectLogs({ level, source, limit = 500, follow_ms = 0 } = {}) {
|
|
const ticketRes = await request("POST", "/admin/api/logs/ticket");
|
|
if (ticketRes.status !== 200 || !ticketRes.body?.ticket) {
|
|
throw new Error(`log ticket request failed: HTTP ${ticketRes.status} ${JSON.stringify(ticketRes.body)}`);
|
|
}
|
|
const url = new URL(`${BASE_URL}/admin/api/logs/stream`);
|
|
url.searchParams.set("ticket", ticketRes.body.ticket);
|
|
|
|
const records = await new Promise((resolve, reject) => {
|
|
const req = https.request(
|
|
url,
|
|
{ method: "GET", agent: httpsAgent(), headers: { Authorization: `Bearer ${TOKEN}`, Accept: "text/event-stream" } },
|
|
(res) => {
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
reject(new Error(`log stream failed: HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
const out = [];
|
|
let buf = "";
|
|
let idle;
|
|
let hard;
|
|
const done = () => {
|
|
clearTimeout(idle);
|
|
clearTimeout(hard);
|
|
req.destroy();
|
|
resolve(out);
|
|
};
|
|
res.setEncoding("utf8");
|
|
res.on("data", (chunk) => {
|
|
buf += chunk;
|
|
let i;
|
|
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
const frame = buf.slice(0, i);
|
|
buf = buf.slice(i + 2);
|
|
const line = frame.split("\n").find((l) => l.startsWith("data: "));
|
|
if (!line) continue; // keepalive comment or blank
|
|
try {
|
|
out.push(JSON.parse(line.slice(6)));
|
|
} catch {
|
|
/* skip malformed frame */
|
|
}
|
|
}
|
|
// In backfill-only mode, finish once the initial burst goes quiet.
|
|
if (follow_ms === 0) {
|
|
clearTimeout(idle);
|
|
idle = setTimeout(done, 300);
|
|
}
|
|
});
|
|
res.on("end", done);
|
|
res.on("error", reject);
|
|
if (follow_ms === 0) idle = setTimeout(done, 300);
|
|
else hard = setTimeout(done, follow_ms);
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
req.end();
|
|
});
|
|
|
|
let rows = records;
|
|
if (level) rows = rows.filter((r) => (r.level || "").toUpperCase() === level.toUpperCase());
|
|
if (source) rows = rows.filter((r) => r.source === source);
|
|
if (rows.length > limit) rows = rows.slice(-limit);
|
|
// attrs arrives as a JSON string on the wire; parse it for readability.
|
|
return rows.map((r) => (r.attrs ? { ...r, attrs: safeParse(r.attrs) } : r));
|
|
}
|
|
|
|
function safeParse(s) {
|
|
try {
|
|
return JSON.parse(s);
|
|
} catch {
|
|
return s;
|
|
}
|
|
}
|
|
|
|
function clientLogs({ lines = 200, level, grep } = {}) {
|
|
let text;
|
|
try {
|
|
text = readFileSync(CLIENT_LOG, "utf8");
|
|
} catch (e) {
|
|
return { path: CLIENT_LOG, found: false, note: `client log not found (client may not have run yet): ${e.code}` };
|
|
}
|
|
let rows = text.split(/\r?\n/).filter(Boolean);
|
|
if (level) rows = rows.filter((l) => l.toUpperCase().includes(`[${level.toUpperCase()}]`));
|
|
if (grep) rows = rows.filter((l) => l.includes(grep));
|
|
return { path: CLIENT_LOG, found: true, lines: rows.slice(-lines) };
|
|
}
|
|
|
|
// ─── MCP wiring ─────────────────────────────────────────────────────────────
|
|
const server = new McpServer({ name: "owncord-introspect", version: "0.1.0" });
|
|
const ok = (obj) => ({ content: [{ type: "text", text: JSON.stringify(obj, null, 2) }] });
|
|
const fail = (e) => ({ content: [{ type: "text", text: String(e?.message || e) }], isError: true });
|
|
|
|
server.registerTool(
|
|
"api_request",
|
|
{
|
|
description:
|
|
"Read-write passthrough to any OwnCord REST endpoint (/api/v1/*, /admin/api/*, plugins). " +
|
|
"Returns {status, headers, body}. Any HTTP method is allowed, including destructive admin routes.",
|
|
inputSchema: {
|
|
method: z.string().describe("HTTP method: GET, POST, PATCH, PUT, DELETE"),
|
|
path: z.string().describe("Path beginning with / (e.g. /admin/api/stats) or a full URL"),
|
|
query: z.record(z.string()).optional().describe("Query params"),
|
|
body: z.any().optional().describe("JSON body (object or string)"),
|
|
headers: z.record(z.string()).optional().describe("Extra request headers"),
|
|
},
|
|
},
|
|
async (a) => {
|
|
try {
|
|
requireToken();
|
|
return ok(await request(a.method, a.path, a));
|
|
} catch (e) {
|
|
return fail(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"server_logs",
|
|
{
|
|
description:
|
|
"Fetch the server's in-memory ring-buffer logs (SSE backfill, optionally follow). " +
|
|
"Each record is {ts, level, msg, source, attrs}. Filters by level/source and applies a limit.",
|
|
inputSchema: {
|
|
level: z.string().optional().describe("DEBUG | INFO | WARN | ERROR"),
|
|
source: z.string().optional().describe("websocket|http|admin|auth|database|storage|updater|config|server"),
|
|
limit: z.number().int().positive().optional().describe("Max records to return (default 500)"),
|
|
follow_ms: z.number().int().nonnegative().optional().describe("0 = backfill only (default); >0 keeps streaming that long"),
|
|
},
|
|
},
|
|
async (a) => {
|
|
try {
|
|
requireToken();
|
|
return ok(await collectLogs(a));
|
|
} catch (e) {
|
|
return fail(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"client_logs",
|
|
{
|
|
description:
|
|
"Tail the desktop client's log file directly (no server needed). Optional level filter / substring grep.",
|
|
inputSchema: {
|
|
lines: z.number().int().positive().optional().describe("How many trailing lines (default 200)"),
|
|
level: z.string().optional().describe("Filter to lines tagged with this level, e.g. ERROR"),
|
|
grep: z.string().optional().describe("Keep only lines containing this substring"),
|
|
},
|
|
},
|
|
async (a) => {
|
|
try {
|
|
return ok(clientLogs(a));
|
|
} catch (e) {
|
|
return fail(e);
|
|
}
|
|
},
|
|
);
|
|
|
|
await server.connect(new StdioServerTransport());
|