335 lines
9.5 KiB
TypeScript
335 lines
9.5 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { cookies } from "next/headers";
|
|
|
|
const AUTH_STATE_ROOT = path.join(
|
|
/* turbopackIgnore: true */ process.cwd(),
|
|
"../../.port_sessions/accounts",
|
|
);
|
|
const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json");
|
|
const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json");
|
|
const COOKIE_NAME = "claw_account_session";
|
|
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
|
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
|
|
const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent";
|
|
|
|
type UserRecord = {
|
|
id: string;
|
|
username: string;
|
|
passwordHash: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type UsersFile = {
|
|
users: UserRecord[];
|
|
};
|
|
|
|
type SessionsFile = {
|
|
sessions: Record<
|
|
string,
|
|
{ accountId: string; createdAt: string; updatedAt?: string }
|
|
>;
|
|
};
|
|
|
|
export type AccountSession = {
|
|
id: string;
|
|
username: string;
|
|
};
|
|
|
|
export async function registerAccount(username: string, password: string) {
|
|
const cleanUsername = normalizeUsername(username);
|
|
validatePassword(password);
|
|
const usersFile = await readUsers();
|
|
if (usersFile.users.some((user) => user.username === cleanUsername)) {
|
|
throw new Error("账号已存在");
|
|
}
|
|
|
|
const account: UserRecord = {
|
|
id: cleanUsername,
|
|
username: cleanUsername,
|
|
passwordHash: hashPassword(password),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
usersFile.users.push(account);
|
|
await ensureLinuxAccount(account.id, password);
|
|
await writeUsers(usersFile);
|
|
await createAccountDirectories(account.id);
|
|
return createSession(account);
|
|
}
|
|
|
|
export async function loginAccount(username: string, password: string) {
|
|
const cleanUsername = normalizeUsername(username);
|
|
const usersFile = await readUsers();
|
|
const account = usersFile.users.find(
|
|
(user) => user.username === cleanUsername,
|
|
);
|
|
if (!account || !verifyPassword(password, account.passwordHash)) {
|
|
throw new Error("账号或密码错误");
|
|
}
|
|
await ensureLinuxAccount(account.id, password);
|
|
await createAccountDirectories(account.id);
|
|
return createSession(account);
|
|
}
|
|
|
|
export async function logoutAccount() {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
if (token) {
|
|
const sessionsFile = await readSessions();
|
|
delete sessionsFile.sessions[token];
|
|
await writeSessions(sessionsFile);
|
|
}
|
|
cookieStore.delete(COOKIE_NAME);
|
|
}
|
|
|
|
export async function getCurrentAccount(): Promise<AccountSession | null> {
|
|
const cookieStore = await cookies();
|
|
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
if (!token) return null;
|
|
|
|
const sessionsFile = await readSessions();
|
|
const session = sessionsFile.sessions[token];
|
|
if (!session) return null;
|
|
|
|
const usersFile = await readUsers();
|
|
const account = usersFile.users.find((user) => user.id === session.accountId);
|
|
if (!account) return null;
|
|
if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) {
|
|
session.updatedAt = new Date().toISOString();
|
|
await writeSessions(sessionsFile);
|
|
await setSessionCookie(token);
|
|
}
|
|
return { id: account.id, username: account.username };
|
|
}
|
|
|
|
export function accountUploadRoot(accountId: string) {
|
|
return path.join(accountBaseRoot(accountId), "sessions");
|
|
}
|
|
|
|
export function accountOutputRoot(accountId: string) {
|
|
return path.join(accountBaseRoot(accountId), "sessions");
|
|
}
|
|
|
|
export function accountBaseRoot(accountId: string) {
|
|
if (linuxAccountsEnabled()) {
|
|
return path.join("/home", accountId, LINUX_ACCOUNT_WORKSPACE_NAME);
|
|
}
|
|
return path.join(AUTH_STATE_ROOT, accountId);
|
|
}
|
|
|
|
export function accountSessionInputRoot(accountId: string, sessionId: string) {
|
|
return path.join(accountBaseRoot(accountId), "sessions", sessionId, "input");
|
|
}
|
|
|
|
export function accountSessionOutputRoot(accountId: string, sessionId: string) {
|
|
return path.join(accountBaseRoot(accountId), "sessions", sessionId, "output");
|
|
}
|
|
|
|
export function accountSessionScratchpadRoot(
|
|
accountId: string,
|
|
sessionId: string,
|
|
) {
|
|
return path.join(
|
|
accountBaseRoot(accountId),
|
|
"sessions",
|
|
sessionId,
|
|
"scratchpad",
|
|
);
|
|
}
|
|
|
|
export function accountSessionRoot(accountId: string, sessionId: string) {
|
|
return path.join(accountBaseRoot(accountId), "sessions", sessionId);
|
|
}
|
|
|
|
async function createSession(account: UserRecord) {
|
|
const token = randomBytes(32).toString("hex");
|
|
const sessionsFile = await readSessions();
|
|
sessionsFile.sessions[token] = {
|
|
accountId: account.id,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
await writeSessions(sessionsFile);
|
|
|
|
await setSessionCookie(token);
|
|
return { id: account.id, username: account.username };
|
|
}
|
|
|
|
async function setSessionCookie(token: string) {
|
|
const cookieStore = await cookies();
|
|
cookieStore.set(COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
path: "/",
|
|
maxAge: SESSION_MAX_AGE_SECONDS,
|
|
});
|
|
}
|
|
|
|
async function createAccountDirectories(accountId: string) {
|
|
const base = accountBaseRoot(accountId);
|
|
await Promise.all([
|
|
mkdir(path.join(base, "sessions"), { recursive: true }),
|
|
mkdir(path.join(base, "python"), { recursive: true }),
|
|
mkdir(path.join(base, "memory"), { recursive: true }),
|
|
mkdir(path.join(base, "integrations"), { recursive: true }),
|
|
]);
|
|
await chownAccountPath(accountId, base, true);
|
|
}
|
|
|
|
async function readUsers(): Promise<UsersFile> {
|
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
|
try {
|
|
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
|
|
} catch {
|
|
return { users: [] };
|
|
}
|
|
}
|
|
|
|
async function writeUsers(payload: UsersFile) {
|
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
|
await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8");
|
|
}
|
|
|
|
async function readSessions(): Promise<SessionsFile> {
|
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
|
try {
|
|
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
|
|
} catch {
|
|
return { sessions: {} };
|
|
}
|
|
}
|
|
|
|
async function writeSessions(payload: SessionsFile) {
|
|
await mkdir(AUTH_STATE_ROOT, { recursive: true });
|
|
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8");
|
|
}
|
|
|
|
function normalizeUsername(username: string) {
|
|
const cleanUsername = username.trim().toLowerCase();
|
|
if (linuxAccountsEnabled()) {
|
|
if (!/^[a-z_][a-z0-9_-]{1,31}$/.test(cleanUsername)) {
|
|
throw new Error(
|
|
"启用 Linux 账号时,账号只能包含小写字母、数字、下划线或中划线,且必须以小写字母或下划线开头,长度 2-32",
|
|
);
|
|
}
|
|
return cleanUsername;
|
|
}
|
|
if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) {
|
|
throw new Error(
|
|
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
|
|
);
|
|
}
|
|
return cleanUsername;
|
|
}
|
|
|
|
function validatePassword(password: string) {
|
|
if (password.length < 6) throw new Error("密码至少 6 位");
|
|
}
|
|
|
|
function hashPassword(password: string) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync(password, salt, 64).toString("hex");
|
|
return `${salt}:${hash}`;
|
|
}
|
|
|
|
function verifyPassword(password: string, passwordHash: string) {
|
|
const [salt, expectedHash] = passwordHash.split(":");
|
|
if (!salt || !expectedHash) return false;
|
|
const actual = Buffer.from(scryptSync(password, salt, 64).toString("hex"));
|
|
const expected = Buffer.from(expectedHash);
|
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
}
|
|
|
|
function shouldRefreshSession(value?: string) {
|
|
if (!value) return true;
|
|
const timestamp = Date.parse(value);
|
|
if (Number.isNaN(timestamp)) return true;
|
|
return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS;
|
|
}
|
|
|
|
function linuxAccountsEnabled() {
|
|
return ["1", "true", "yes", "on"].includes(
|
|
(process.env.CLAW_ENABLE_LINUX_ACCOUNTS ?? "").trim().toLowerCase(),
|
|
);
|
|
}
|
|
|
|
async function ensureLinuxAccount(accountId: string, password: string) {
|
|
if (!linuxAccountsEnabled()) return;
|
|
if (process.platform !== "linux") {
|
|
throw new Error("CLAW_ENABLE_LINUX_ACCOUNTS=1 只支持 Linux 部署环境");
|
|
}
|
|
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
throw new Error(
|
|
"CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 Web 服务以 root 身份运行",
|
|
);
|
|
}
|
|
await runCommand("id", ["-u", accountId], { allowFailure: true }).then(
|
|
async (result) => {
|
|
if (result.code === 0) return;
|
|
await runCommand("useradd", [
|
|
"-m",
|
|
"-d",
|
|
`/home/${accountId}`,
|
|
"-s",
|
|
"/bin/bash",
|
|
accountId,
|
|
]);
|
|
},
|
|
);
|
|
await runCommand("chpasswd", [], {
|
|
input: `${accountId}:${password}\n`,
|
|
});
|
|
}
|
|
|
|
export async function chownAccountPath(
|
|
accountId: string,
|
|
targetPath: string,
|
|
recursive = false,
|
|
) {
|
|
if (!linuxAccountsEnabled()) return;
|
|
await runCommand("chown", [
|
|
...(recursive ? ["-R"] : []),
|
|
accountId,
|
|
targetPath,
|
|
]);
|
|
}
|
|
|
|
function runCommand(
|
|
command: string,
|
|
args: string[],
|
|
options: { input?: string; allowFailure?: boolean } = {},
|
|
) {
|
|
return new Promise<{ code: number; stdout: string; stderr: string }>(
|
|
(resolve, reject) => {
|
|
const child = spawn(command, args, {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) => {
|
|
const exitCode = code ?? 1;
|
|
if (exitCode !== 0 && !options.allowFailure) {
|
|
reject(
|
|
new Error(
|
|
`${command} ${args.join(" ")} 失败:${stderr || stdout || exitCode}`,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
resolve({ code: exitCode, stdout, stderr });
|
|
});
|
|
if (options.input) child.stdin.write(options.input);
|
|
child.stdin.end();
|
|
},
|
|
);
|
|
}
|