Files
crossdesk/apps/mcp/src/store.ts
T

230 lines
7.9 KiB
TypeScript

import { createHash } from "node:crypto";
import {
mkdir,
readFile,
writeFile,
rename,
unlink,
open,
chmod,
} from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { z } from "zod";
import {
alias,
host,
mode,
panel,
videoOptions,
Failure,
parse,
token,
unlockPassword,
type Data,
} from "./common.js";
const deviceSchema = z.strictObject({
alias,
device_id: z.string().regex(/^\d{9}$/),
server: host,
port: z.int().min(1).max(65535),
authorization: mode.optional(),
credential: z.enum(["keyring", "environment"]).optional(),
environment_variable: z
.string()
.regex(/^[A-Z][A-Z0-9_]{0,127}$/)
.optional(),
unlock_credential: z.enum(["keyring", "environment"]).optional(),
unlock_environment_variable: z.string().regex(/^[A-Z][A-Z0-9_]{0,127}$/).optional(),
});
export type Device = z.infer<typeof deviceSchema>;
const configSchema = z.strictObject({
version: z.literal(1),
panel: panel.default("auto"),
video: videoOptions.prefault({}),
server: host.default("api.crossdesk.cn"),
port: z.int().min(1).max(65535).default(9099),
devices: z.array(deviceSchema).max(256),
});
export type Config = z.infer<typeof configSchema>;
export class DeviceStore {
readonly directory: string;
readonly file: string;
constructor(directory?: string) {
this.directory = resolve(
directory ?? join(homedir(), ".config", "crossdesk", "mcp"),
);
this.file = join(this.directory, "devices.json");
}
async initialize() {
await mkdir(this.directory, { recursive: true, mode: 0o700 });
if (process.platform !== "win32") await chmod(this.directory, 0o700);
await mkdir(join(this.directory, "logs"), { recursive: true, mode: 0o700 });
}
async config(): Promise<Config> {
try {
return parse(configSchema, JSON.parse(await readFile(this.file, "utf8")));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
return configSchema.parse({ version: 1, devices: [] });
throw new Failure("CONFIG_INVALID");
}
}
async list(): Promise<Data[]> {
return (await this.config()).devices.map((d) => ({
device_alias: d.alias,
device_id: d.device_id,
server: d.server,
port: d.port,
authorization: d.authorization ?? "none",
credential_provider: d.credential ?? "none",
unlock_credential_provider: d.unlock_credential ?? "none",
}));
}
async device(name: string): Promise<Device> {
const d = (await this.config()).devices.find((d) => d.alias === name);
if (!d) throw new Failure("DEVICE_NOT_FOUND");
return d;
}
private account(d: Device, purpose: "connection" | "unlock") {
const identity = [this.directory, d.server, d.port, d.device_id];
if (purpose === "unlock") identity.push("unlock");
return createHash("sha256")
.update(JSON.stringify(identity))
.digest("hex");
}
private async entry(d: Device, purpose: "connection" | "unlock" = "connection") {
try {
const { AsyncEntry } = await import("@napi-rs/keyring");
return new AsyncEntry("crossdesk-mcp", this.account(d, purpose), {
linux: { store: "secret-service" },
});
} catch {
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
}
}
async password(d: Device, purpose: "connection" | "unlock" = "connection"): Promise<string> {
const provider = purpose === "unlock" ? d.unlock_credential : d.credential;
const variable = purpose === "unlock" ? d.unlock_environment_variable : d.environment_variable;
if (provider === "environment") {
const secret =
variable && process.env[variable];
if (secret !== undefined) return secret;
} else if (provider === "keyring") {
try {
const secret = await (await this.entry(d, purpose)).getPassword();
if (secret !== null) return secret;
} catch {
throw new Failure("CREDENTIAL_STORE_UNAVAILABLE");
}
}
throw new Failure(purpose === "unlock" ? "UNLOCK_CREDENTIAL_REQUIRED" : "AUTH_REQUIRED");
}
async save(d: Device, password?: string, unlockSecret?: string, update: "device" | "unlock" = "device") {
parse(deviceSchema, d);
if (unlockSecret !== undefined) parse(unlockPassword, unlockSecret);
const lock = await this.lock("config");
const restore: Array<() => Promise<unknown>> = [];
try {
const config = await this.config();
const previous = config.devices.find((item) => item.alias === d.alias);
if (
previous &&
(previous.device_id !== d.device_id ||
previous.server !== d.server ||
previous.port !== d.port)
)
throw new Failure("DEVICE_ALIAS_CONFLICT");
if (update === "unlock") {
if (!previous) throw new Failure("SAVE_DEVICE_REQUIRED");
d = { ...previous };
}
if (password !== undefined) {
d.credential = "keyring";
delete d.environment_variable;
}
// Keep separately configured system credentials when updating connection settings.
if (previous && !d.unlock_credential) {
d.unlock_credential = previous.unlock_credential;
d.unlock_environment_variable = previous.unlock_environment_variable;
}
if (unlockSecret !== undefined) {
d.unlock_credential = "keyring";
delete d.unlock_environment_variable;
}
config.devices = [
...config.devices.filter((item) => item.alias !== d.alias),
d,
];
parse(configSchema, config);
const saveSecret = async (purpose: "connection" | "unlock", secret: string) => {
try {
const entry = await this.entry(d, purpose);
const previous = await entry.getPassword();
restore.push(() => previous === null ? entry.deletePassword() : entry.setPassword(previous));
await entry.setPassword(secret);
} catch { throw new Failure("CREDENTIAL_STORE_UNAVAILABLE"); }
};
if (password !== undefined) await saveSecret("connection", password);
if (unlockSecret !== undefined) await saveSecret("unlock", unlockSecret);
const temporary = `${this.file}.${token()}.tmp`;
try {
await writeFile(temporary, JSON.stringify(config, null, 2) + "\n", {
mode: 0o600,
flag: "wx",
});
await rename(temporary, this.file);
} finally {
await unlink(temporary).catch(() => {});
}
} catch (error) {
let restored = true;
for (const undo of restore.reverse()) {
try { await undo(); } catch { restored = false; }
}
if (!restored) throw new Failure("CREDENTIAL_ROLLBACK_FAILED");
throw error;
} finally {
await lock();
}
}
async saveUnlock(d: Device, password: string) {
await this.save(d, undefined, password, "unlock");
}
async lock(name: string): Promise<() => Promise<void>> {
const path = join(
this.directory,
`lock-${createHash("sha256").update(name).digest("hex").slice(0, 24)}`,
);
for (let attempt = 0; attempt < 2; ++attempt) {
try {
const handle = await open(path, "wx", 0o600);
await handle.writeFile(String(process.pid));
await handle.close();
let released = false;
return async () => {
if (released) return;
released = true;
await unlink(path).catch(() => {});
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST")
throw new Failure("CONFIG_WRITE_FAILED");
const pid = Number(await readFile(path, "utf8").catch(() => "0"));
if (!Number.isSafeInteger(pid) || pid < 1)
throw new Failure("DEVICE_BUSY");
try {
process.kill(pid, 0);
throw new Failure("DEVICE_BUSY");
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ESRCH")
throw new Failure("DEVICE_BUSY");
}
await unlink(path).catch(() => {});
}
}
throw new Failure("DEVICE_BUSY");
}
}