Files
librechat-custom/sync-keys.js
T
inference-admin e544dc4a11 key sync: read CREDS_KEY/CREDS_IV from the volume, add a failure diagnostic
The first scheduled run failed closed (good) — Cloudron's scheduler addon runs
each task in its OWN container, so the app's process env is absent there and
/proc/1/environ belongs to the task launcher, not to LibreChat. CREDS_KEY/CREDS_IV
now come from /app/data/.librechat-creds (root:cloudron 0640), written from the
live app env; a stale key makes the decrypt-against-existing-rows guard refuse to
write rather than corrupt member keys. Credential failure now also writes
/app/data/key-sync-diag.txt so a future failure explains itself.

Verified: the 22:40 scheduled fire logged 'creds check: decrypt-ok (25 existing
row(s))' / 'done: 50 seeded, 15 skipped'.
2026-10-25 22:42:13 +00:00

165 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
/*
* sync-keys.js — seed every active member's chat key into LibreChat's
* per-user key store so members NEVER see an API-key prompt.
*
* keys[name="Inference Co-op"] <- {apiKey: <sk>} (chat endpoint)
* pluginauths[mcp:inference-image] <- IC_API_KEY (image MCP)
*
* Values are encrypted with the SAME crypto LibreChat uses (data-schemas
* encrypt(): AES-256-CBC, CREDS_KEY/CREDS_IV from the app process env), so
* LibreChat decrypts them natively.
*
* Run: cd /app/code/librechat/api && node /app/data/sync-keys.js
* (requires CLOUDRON_MONGODB_URL in the environment — present in `cloudron exec`)
* Idempotent: safe to run repeatedly.
*
* SCHEDULED INSIDE THIS APP (2026-10-25): the manifest's `scheduler` addon runs
* this every 10 minutes, so seeding no longer depends on any machine outside
* the co-op's Cloudron box. Two guards keep a bad run from damaging members:
* it refuses to write unless CREDS_KEY can first decrypt an existing key row
* (proof it is the app's real key), and unless each fresh ciphertext round-trips
* back to the plaintext.
*
* CREDS_KEY/CREDS_IV: a scheduled run happens in the scheduler's own container,
* where the app's process env does not exist (/proc/1/environ there belongs to
* the task launcher). They are read from /app/data/.librechat-creds
* (root:cloudron 0640) instead. If the app's key ever rotates, refresh that file
* — a stale key makes the decrypt guard refuse to write rather than corrupt
* member keys.
*/
const fs = require("fs");
const os = require("os");
const path = require("path");
// --- env assembly: exec sessions lack the app's process env ---------------
function loadEnvFile(p) {
try {
for (const line of fs.readFileSync(p, "utf8").split("\n")) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
} catch (e) { /* optional */ }
}
loadEnvFile("/app/data/env");
try { // CREDS_KEY / CREDS_IV / MONGODB live in the app process env
for (const line of fs.readFileSync("/proc/1/environ", "utf8").split("\0")) {
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2];
}
} catch (e) { /* optional */ }
const MONGO_URL = process.env.CLOUDRON_MONGODB_URL || process.env.MONGODB_URL;
const PORTAL_URL = process.env.PORTAL_URL || "https://portal.inference.coop";
const SYNC_TOKEN = process.env.LIBRECHAT_SYNC_TOKEN || "";
const ENDPOINT_NAME = "Inference Co-op";
const MCP_PLUGIN_KEY = "mcp_inference-image" // Constants.mcp_prefix is "mcp_" (underscore), not "mcp:";
const MCP_AUTH_FIELD = "IC_API_KEY";
if (!MONGO_URL) { console.error("FATAL: no MongoDB URL in environment"); process.exit(1); }
if (!SYNC_TOKEN) { console.error("FATAL: LIBRECHAT_SYNC_TOKEN not set"); process.exit(1); }
// Scheduler tasks run in their OWN container (Cloudron's scheduler addon): the
// app's process env is absent and /proc/1/environ belongs to the task launcher,
// not the app. So the app's CREDS_KEY/CREDS_IV are read from a root- and
// group-readable file on the shared volume, written from the live app env.
// If it is missing or stale, the guards below stop the run instead of writing
// keys the app cannot decrypt.
const CREDS_FILE = "/app/data/.librechat-creds";
try {
for (const line of fs.readFileSync(CREDS_FILE, "utf8").split("\n")) {
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (m && /^CREDS_(KEY|IV)$/.test(m[1]) && process.env[m[1]] === undefined) process.env[m[1]] = m[2].trim();
}
} catch (e) { /* absent is fine — the guard below reports it */ }
if (!process.env.CREDS_KEY || !process.env.CREDS_IV) {
const diag = [
`when=${new Date().toISOString()}`,
`uid=${typeof process.getuid === "function" ? process.getuid() : "?"} gid=${typeof process.getgid === "function" ? process.getgid() : "?"}`,
`cwd=${process.cwd()}`,
`creds_file_exists=${fs.existsSync(CREDS_FILE)}`,
`proc1_environ_readable=${(() => { try { return fs.readFileSync("/proc/1/environ").length > 0; } catch (e) { return false; } })()}`,
`env_names_with_creds=${Object.keys(process.env).filter((k) => /CREDS/i.test(k)).join(",") || "none"}`,
`volume_mounted=${fs.existsSync("/app/data")}`,
].join("\n");
try { fs.writeFileSync("/app/data/key-sync-diag.txt", diag + "\n"); } catch (e) { /* volume not mounted */ }
console.error("FATAL: CREDS_KEY/CREDS_IV not available — refusing to write (keys would be undecryptable); diag: /app/data/key-sync-diag.txt");
process.exit(1);
}
// data-schemas resolves CREDS_KEY/CREDS_IV at require time — env is ready above
const { encrypt, decrypt } = require("/app/code/librechat/node_modules/@librechat/data-schemas");
const mongoose = require("/app/code/librechat/node_modules/mongoose");
(async () => {
// 1. Fetch active members' chat keys from the portal (dedicated sync token)
const res = await fetch(`${PORTAL_URL}/admin/librechat/keys`, {
headers: { "X-Sync-Token": SYNC_TOKEN },
});
if (!res.ok) {
console.error(`FATAL: portal key feed returned ${res.status}`);
process.exit(1);
}
const { members } = await res.json();
console.log(`portal feed: ${members.length} active member key(s)`);
await mongoose.connect(MONGO_URL);
const db = mongoose.connection.db;
const users = db.collection("users");
const keys = db.collection("keys");
const auths = db.collection("pluginauths");
// --- safety: prove CREDS_KEY is the app's own key before writing anything --
// A row that decrypts to a real key proves we hold the key the app encrypted
// with. If rows exist and none decrypt, our CREDS_KEY is wrong, and writing
// would replace working member keys with undecryptable ones — so refuse.
const sample = await keys.find({ name: ENDPOINT_NAME }).limit(25).toArray();
let credsCheck = sample.length === 0 ? "no-existing-rows" : null;
for (const row of sample) {
try {
const obj = JSON.parse(await decrypt(row.value));
if (obj && typeof obj.apiKey === "string" && obj.apiKey.length > 8) { credsCheck = "decrypt-ok"; break; }
} catch (e) { /* try the next row */ }
}
if (!credsCheck) {
console.error(`FATAL: CREDS_KEY decrypts none of ${sample.length} existing key row(s) — refusing to write`);
process.exit(1);
}
console.log(`creds check: ${credsCheck} (${sample.length} existing row(s))`);
let seeded = 0, skipped = 0;
for (const m of members) {
const email = String(m.email || "").toLowerCase();
const sk = String(m.key || "");
if (!email || !sk) continue;
const user = await users.findOne({
email: { $regex: new RegExp(`^${email.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") },
});
if (!user) { skipped++; continue; } // not logged in to the sandbox yet
const uid = user._id;
// chat endpoint key: value is a JSON object string, same shape the UI saves
const encChat = await encrypt(JSON.stringify({ apiKey: sk }));
if (JSON.parse(await decrypt(encChat)).apiKey !== sk) {
console.error("FATAL: encrypt/decrypt round-trip mismatch — refusing to write");
process.exit(1);
}
await keys.updateOne(
{ userId: uid, name: ENDPOINT_NAME },
{ $set: { userId: uid, name: ENDPOINT_NAME, value: encChat } },
{ upsert: true }
);
// image MCP credential
const encImage = await encrypt(sk);
await auths.updateOne(
{ userId: uid, pluginKey: MCP_PLUGIN_KEY, authField: MCP_AUTH_FIELD },
{ $set: { userId: uid, pluginKey: MCP_PLUGIN_KEY, authField: MCP_AUTH_FIELD, value: encImage } },
{ upsert: true }
);
seeded++;
console.log(`seeded ${email} (user ${String(uid).slice(-6)})`);
}
console.log(`done: ${seeded} seeded, ${skipped} skipped (no sandbox account yet)`);
await mongoose.disconnect();
})().catch((e) => { console.error("FATAL:", e); process.exit(1); });