src/services/local-n8n-stack-credentials.js
Rama main · réplica de lectura del repositorio original.
import { createHash, randomUUID } from "node:crypto";
import * as defaultFileSystem from "node:fs/promises";
import { homedir, platform as hostPlatform } from "node:os";
import { join } from "node:path";
import { validateLocalN8nStackMarker, getLocalN8nStackLabels } from "../domain/local-n8n-stack.js";
import { lockDownLocalPath, runLocalProcess, validateLocalDockerHost } from "../infrastructure/local-process.js";
import { resolveLocalN8nStackInstallRoot, writePrivateFile } from "./local-n8n-stack-installer.js";
const FEATURES = Object.freeze({
chatgpt: { name: "Relmio ChatGPT plan", url: "http://n8n-openai-oauth:10531/v1" },
"local-model": { name: "Relmio local model", url: "http://n8n-local-model:11434/v1" },
supergrok: { name: "Relmio SuperGrok", url: "http://n8n-supergrok:14502/v1" },
claude: { name: "Relmio Claude", url: "http://relmio-claude:14502/v1" },
});
const ID = /^[a-f0-9]{64}$/u;
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
export function localStackCredentialId(installId, feature) {
if (!/^[a-f0-9]{32}$/u.test(installId) || !Object.hasOwn(FEATURES, feature)) throw new TypeError("Invalid credential identity.");
let value = BigInt(`0x${createHash("sha256").update(`${installId}:${feature}`).digest("hex")}`);
let encoded = "";
for (let index = 0; index < 16; index++) {
encoded = ALPHABET[Number(value % 62n)] + encoded;
value /= 62n;
}
return encoded;
}
export async function importLocalStackN8nCredential({
marker, credential, runProcess = runLocalProcess, dockerHost,
fileSystem = defaultFileSystem, homeDirectory = homedir(),
env = process.env, platform = hostPlatform(), cwd = process.cwd(),
lockDownPath = lockDownLocalPath,
} = {}) {
const feature = credential?.feature;
const settings = Object.hasOwn(FEATURES, feature ?? "") ? FEATURES[feature] : null;
const response = { state: "failed", name: settings?.name ?? "Relmio credential" };
try {
if (!settings || typeof credential.apiKey !== "string" || !credential.apiKey ||
credential.apiKey.length > 512 || /[\0\r\n]/u.test(credential.apiKey)) return response;
const safe = validateLocalN8nStackMarker(marker);
if (validateLocalDockerHost(dockerHost, { platform }) !== safe.dockerHost) return response;
const root = await resolveLocalN8nStackInstallRoot({ fileSystem, homeDirectory, env, platform });
const markerPath = join(root, ".managed-by-relmio.json");
const markerStat = await fileSystem.lstat(markerPath);
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.size > 4096 ||
(platform !== "win32" && (markerStat.mode & 0o077) !== 0)) return response;
await lockDownPath(markerPath, { platform, kind: "file", verifyOnly: true, verifyEffectiveOwnerOnly: true });
const onDisk = validateLocalN8nStackMarker(JSON.parse(await fileSystem.readFile(markerPath, "utf8")));
if (onDisk.installId !== safe.installId || onDisk.projectName !== safe.projectName ||
onDisk.dockerHost !== safe.dockerHost) return response;
const containerName = `${safe.projectName}-n8n-1`;
const inspected = await runProcess({ file: "docker", args: ["container", "inspect", "--format", "{{json .}}", containerName],
cwd: root, dockerHost, timeoutMs: 60_000, maxOutputBytes: 32_768 });
if (inspected.code !== 0 || Buffer.byteLength(inspected.stdout ?? "") > 32_768) return response;
const container = JSON.parse(inspected.stdout);
const labels = container?.Config?.Labels;
if (!ID.test(container?.Id) || container.Name !== `/${containerName}` ||
container.State?.Running !== true || labels?.["com.docker.compose.project"] !== safe.projectName ||
labels?.["com.docker.compose.service"] !== "n8n" ||
Object.entries(getLocalN8nStackLabels(safe)).some(([key, value]) => labels?.[key] !== value)) return response;
const runtime = join(root, ".runtime");
const directory = await fileSystem.lstat(runtime);
if (!directory.isDirectory() || directory.isSymbolicLink() ||
(platform !== "win32" && (directory.mode & 0o077) !== 0)) return response;
await lockDownPath(runtime, { platform, verifyOnly: true });
const recordPath = join(runtime, "credentials.json");
let previous = {};
try {
const stat = await fileSystem.lstat(recordPath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096 ||
(platform !== "win32" && (stat.mode & 0o077) !== 0)) return response;
await lockDownPath(recordPath, { platform, kind: "file", verifyOnly: true, verifyEffectiveOwnerOnly: true });
previous = JSON.parse(await fileSystem.readFile(recordPath, "utf8"));
if (!previous || typeof previous !== "object" || Array.isArray(previous) ||
Object.entries(previous).some(([name, entry]) => !Object.hasOwn(FEATURES, name) ||
entry?.id !== localStackCredentialId(safe.installId, name) ||
entry.name !== FEATURES[name].name || !Number.isSafeInteger(entry.importedAt))) return response;
} catch (error) {
if (error?.code !== "ENOENT") return response;
}
const id = localStackCredentialId(safe.installId, feature);
const input = JSON.stringify([{ id, name: settings.name, type: "openAiApi",
data: { apiKey: credential.apiKey, url: settings.url } }]);
const imported = await runProcess({ file: "docker",
args: ["exec", "-i", container.Id, "n8n", "import:credentials", "--input=/dev/stdin"],
input, cwd: root, dockerHost, timeoutMs: 60_000, maxOutputBytes: 4096 });
if (imported.code !== 0) return response;
const temporary = join(runtime, `credentials.json-${randomUUID()}`);
await writePrivateFile(fileSystem, temporary,
`${JSON.stringify({ ...previous, [feature]: { id, name: settings.name, importedAt: Date.now() } })}\n`,
0o600, { platform, lockDownPath });
try { await fileSystem.rename(temporary, recordPath); }
catch { await fileSystem.unlink(temporary); return response; }
await lockDownPath(recordPath, { platform, kind: "file", verifyOnly: true, verifyEffectiveOwnerOnly: true });
return { state: previous[feature] ? "updated" : "created", name: settings.name };
} catch {
return response;
}
}