feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+337
View File
@@ -0,0 +1,337 @@
import {appendFileSync, readFileSync} from "node:fs";
const managedApplications = new Map([
["xiteng-portal-admin", "Portal"],
["xiteng-portal", "Portal Home"],
["xiteng-chat", "Xiteng Chat"],
["code-server", "Code Server"],
["comfyui", "ComfyUI"],
["invokeai", "InvokeAI"],
["gitea", "Gitea"],
["hedgedoc", "HedgeDoc"],
["hedgedoc2", "HedgeDoc 2"],
["minio", "SeaweedFS Console"],
["seaweedfs", "SeaweedFS"],
["remark42", "Remark42"]
]);
function text(value, maximum = 160) {
return typeof value === "string" ? value.trim().slice(0, maximum) : "";
}
function readAudit(path) {
try {
return readFileSync(path, "utf8").trim().split("\n").filter(Boolean).slice(-100).reverse().map(JSON.parse);
} catch {
return [];
}
}
function reject(message, statusCode = 400) {
const error = new Error(message);
error.statusCode = statusCode;
throw error;
}
function writeAudit(path, actor, action, target, detail = "") {
appendFileSync(path, `${JSON.stringify({createdAt: new Date().toISOString(), actor, action, target, detail})}\n`, {mode: 0o600});
}
export function createAuthentikAdmin({baseUrl, token, auditPath, adminUsername = "liooil", protectedGroup = "liuhome"}) {
async function request(pathname, {method = "GET", body} = {}) {
if (!token) {
const error = new Error("Authentik API token is not configured");
error.statusCode = 503;
throw error;
}
const response = await fetch(new URL(`/api/v3${pathname}`, baseUrl), {
method,
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${token}`,
...(body === undefined ? {} : {"Content-Type": "application/json"})
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10000)
});
const payload = response.status === 204 ? null : await response.json().catch(() => null);
if (!response.ok) {
const detail = payload && typeof payload === "object"
? Object.entries(payload).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`).join("; ")
: `HTTP ${response.status}`;
const error = new Error(detail || `Authentik HTTP ${response.status}`);
error.statusCode = response.status;
throw error;
}
return payload;
}
async function page(pathname) {
const payload = await request(pathname);
return Array.isArray(payload) ? payload : payload?.results || [];
}
async function groupByName(name) {
const groups = await page(`/core/groups/?name=${encodeURIComponent(name)}&include_users=true&page_size=100`);
return groups.find((group) => group.name === name) || null;
}
async function userByUsername(username) {
const users = await page(`/core/users/?username=${encodeURIComponent(username)}&include_groups=true&page_size=10`);
const user = users.find((item) => item.username === username && item.type === "internal");
if (!user) reject("Authentik 用户不存在", 404);
return user;
}
async function securityForUser(user) {
const [totpDevices, passkeys, sessions] = await Promise.all([
page("/authenticators/admin/totp/?page_size=200"),
page("/authenticators/admin/webauthn/?page_size=200"),
page(`/core/authenticated_sessions/?user__username=${encodeURIComponent(user.username)}&page_size=100`)
]);
return {
totp: totpDevices.filter((device) => device.user?.pk === user.pk).map((device) => ({id: device.pk, name: device.name})),
passkeys: passkeys.filter((device) => device.user?.pk === user.pk).map((device) => ({
id: device.pk,
name: device.name,
createdAt: device.created_on,
deviceType: device.device_type,
aaguid: device.aaguid
})),
sessions: sessions.filter((session) => session.user === user.pk).map((session) => ({
id: session.uuid,
current: session.current,
ip: session.last_ip,
userAgent: session.last_user_agent,
lastUsed: session.last_used,
expires: session.expires
}))
};
}
async function accountSummary(username) {
const user = await userByUsername(username);
const security = await securityForUser(user);
const next = encodeURIComponent("https://xiteng.site/account");
return {
profile: {
id: user.pk,
uuid: user.uuid,
username: user.username,
name: user.name,
email: user.email,
groups: (user.groups_obj || []).map((group) => group.name),
},
security: {
...security,
totpSetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-totp-setup/?next=${next}`,
passkeySetupUrl: `${baseUrl.replace(/\/$/, "")}/if/flow/default-authenticator-webauthn-setup/?next=${next}`
}
};
}
async function accountMutate(pathname, body, username) {
const user = await userByUsername(username);
let result;
let action;
if (pathname === "/api/account/profile") {
result = await request(`/core/users/${user.pk}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}});
action = "profile.update";
} else if (pathname === "/api/account/security/password") {
result = await request(`/core/users/${user.pk}/recovery/`, {method: "POST", body: {token_duration: "minutes=15"}});
action = "password.recovery";
} else {
const deviceAction = pathname.match(/^\/api\/account\/security\/(totp|passkeys)\/(\d+)\/(rename|delete)$/);
const sessionAction = pathname.match(/^\/api\/account\/security\/sessions\/([0-9a-f-]+)\/delete$/);
if (deviceAction) {
const [, kind, id, operation] = deviceAction;
const endpoint = kind === "totp" ? "totp" : "webauthn";
const device = await request(`/authenticators/admin/${endpoint}/${id}/`);
if (device.user?.pk !== user.pk) reject("认证设备不属于当前用户", 404);
result = operation === "rename"
? await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "PATCH", body: {name: text(body.name, 200)}})
: await request(`/authenticators/admin/${endpoint}/${id}/`, {method: "DELETE"});
action = `${kind}.${operation}`;
} else if (sessionAction) {
const session = await request(`/core/authenticated_sessions/${sessionAction[1]}/`);
if (session.user !== user.pk) reject("会话不属于当前用户", 404);
result = await request(`/core/authenticated_sessions/${sessionAction[1]}/`, {method: "DELETE"});
action = "session.delete";
} else if (pathname === "/api/account/security/sessions/all") {
result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${user.pk}`, {method: "DELETE"});
action = "session.delete_all";
} else {
reject("Account action not found", 404);
}
}
writeAudit(auditPath, username, action, username, "{}");
return result;
}
async function summary(search = "") {
const query = new URLSearchParams({type: "internal", include_groups: "true", page_size: "100"});
if (search) query.set("search", search);
const [users, groups, applications, bindings, totpDevices, passkeys, health] = await Promise.all([
page(`/core/users/?${query}`),
page("/core/groups/?include_users=true&page_size=100"),
page("/core/applications/?superuser_full_list=true&page_size=100"),
page("/policies/bindings/?page_size=200"),
page("/authenticators/admin/totp/?page_size=200"),
page("/authenticators/admin/webauthn/?page_size=200"),
fetch(new URL("/-/health/ready/", baseUrl), {signal: AbortSignal.timeout(3000)}).then((response) => response.ok).catch(() => false)
]);
const humanUsers = users.filter((user) => user.type === "internal" && user.path === "users" && user.username !== "AnonymousUser" && !user.username.startsWith("ak-"));
const ordinaryGroups = groups.filter((group) => !group.is_superuser && !group.name.startsWith("authentik "));
const groupNames = new Map(groups.map((group) => [group.pk, group.name]));
const applicationBySlug = new Map(applications.map((application) => [application.slug, application]));
return {
configured: Boolean(token),
healthy: health,
protectedGroup,
users: humanUsers.map((user) => ({
id: user.pk,
uuid: user.uuid,
username: user.username,
name: user.name,
email: user.email,
active: user.is_active,
administrator: user.is_superuser,
lastLogin: user.last_login,
createdAt: user.date_joined,
groups: (user.groups_obj || []).map((group) => ({id: group.pk, name: group.name})),
totpCount: totpDevices.filter((device) => device.user?.pk === user.pk).length,
passkeyCount: passkeys.filter((device) => device.user?.pk === user.pk).length
})),
groups: ordinaryGroups.map((group) => ({
id: group.pk,
name: group.name,
userIds: group.users || [],
users: (group.users_obj || []).map((user) => ({id: user.pk, username: user.username, name: user.name}))
})),
applications: [...managedApplications].map(([slug, fallbackName]) => {
const application = applicationBySlug.get(slug);
const allowedGroupIds = application
? bindings.filter((binding) => binding.target === application.pk && binding.enabled !== false && binding.group).map((binding) => binding.group)
: [];
return {
slug,
name: application?.name || fallbackName,
available: Boolean(application),
allowedGroupIds,
allowedGroups: allowedGroupIds.map((id) => groupNames.get(id) || id)
};
}),
audit: readAudit(auditPath)
};
}
async function mutate(pathname, body, actor) {
let result;
let action;
let target;
if (pathname === "/api/admin/identity/users") {
const username = text(body.username, 150);
if (!/^[A-Za-z0-9@._-]+$/.test(username)) reject("用户名只能包含字母、数字和 @._-");
const defaultGroup = await groupByName(protectedGroup);
const groups = Array.isArray(body.groupIds) && body.groupIds.length ? body.groupIds : defaultGroup ? [defaultGroup.pk] : [];
result = await request("/core/users/", {method: "POST", body: {
username,
name: text(body.name) || username,
email: text(body.email, 254),
is_active: true,
path: "users",
type: "internal",
groups
}});
action = "user.create";
target = username;
} else {
const userAction = pathname.match(/^\/api\/admin\/identity\/users\/(\d+)\/(update|enable|disable|password|recovery|sessions|reset-totp|reset-passkeys)$/);
const groupAction = pathname.match(/^\/api\/admin\/identity\/groups\/([0-9a-f-]+)\/(update|delete|members)$/);
const appAction = pathname.match(/^\/api\/admin\/identity\/applications\/([a-z0-9-]+)\/groups$/);
if (userAction) {
const [, id, operation] = userAction;
const user = await request(`/core/users/${id}/`);
if (user.username === adminUsername && operation === "disable") reject(`不能停用管理员 ${adminUsername}`, 409);
if (operation === "update") {
result = await request(`/core/users/${id}/`, {method: "PATCH", body: {name: text(body.name), email: text(body.email, 254)}});
} else if (["enable", "disable"].includes(operation)) {
result = await request(`/core/users/${id}/`, {method: "PATCH", body: {is_active: operation === "enable"}});
} else if (operation === "password") {
const password = text(body.password, 1024);
if (password.length < 12) reject("临时密码至少需要 12 个字符");
await request(`/core/users/${id}/set_password/`, {method: "POST", body: {password}});
result = {changed: true};
} else if (operation === "recovery") {
result = await request(`/core/users/${id}/recovery/`, {method: "POST", body: {token_duration: "hours=1"}});
} else if (["reset-totp", "reset-passkeys"].includes(operation)) {
const endpoint = operation === "reset-totp" ? "totp" : "webauthn";
const devices = (await page(`/authenticators/admin/${endpoint}/?page_size=200`)).filter((device) => device.user?.pk === user.pk);
await Promise.all(devices.map((device) => request(`/authenticators/admin/${endpoint}/${device.pk}/`, {method: "DELETE"})));
result = {deleted: devices.length};
} else {
result = await request(`/core/authenticated_sessions/bulk_delete/?user_pks=${id}`, {method: "DELETE"});
}
action = `user.${operation}`;
target = user.username;
} else if (pathname === "/api/admin/identity/groups") {
const name = text(body.name);
if (!name) reject("用户组名称不能为空");
result = await request("/core/groups/", {method: "POST", body: {name, is_superuser: false}});
action = "group.create";
target = name;
} else if (groupAction) {
const [, id, operation] = groupAction;
const group = await request(`/core/groups/${id}/?include_users=true`);
if (group.name === protectedGroup && ["update", "delete"].includes(operation)) reject(`不能修改或删除基础用户组 ${protectedGroup}`, 409);
if (operation === "update") {
result = await request(`/core/groups/${id}/`, {method: "PATCH", body: {name: text(body.name)}});
} else if (operation === "delete") {
result = await request(`/core/groups/${id}/`, {method: "DELETE"});
} else {
const userId = Number.parseInt(body.userId, 10);
const user = await request(`/core/users/${userId}/`);
if (group.name === protectedGroup && [adminUsername, "ziyue"].includes(user.username) && body.member === false) {
reject(`${user.username} 必须保留在 ${protectedGroup}`, 409);
}
await request(`/core/groups/${id}/${body.member === false ? "remove_user" : "add_user"}/`, {method: "POST", body: {pk: userId}});
result = {changed: true};
}
action = `group.${operation}`;
target = group.name;
} else if (appAction) {
const slug = appAction[1];
if (!managedApplications.has(slug)) reject("应用不在 Portal 管理范围内", 404);
const application = await request(`/core/applications/${slug}/`);
const current = await page(`/policies/bindings/?target=${application.pk}&page_size=100`);
await Promise.all(current.map((binding) => request(`/policies/bindings/${binding.pk}/`, {method: "DELETE"})));
const groupIds = [...new Set(Array.isArray(body.groupIds) ? body.groupIds : [])];
await Promise.all(groupIds.map((group) => request("/policies/bindings/", {method: "POST", body: {
target: application.pk,
group,
order: 0,
enabled: true,
negate: false,
failure_result: false
}})));
result = {allowedGroupIds: groupIds};
action = "application.groups";
target = slug;
} else {
const error = new Error("Identity action not found");
error.statusCode = 404;
throw error;
}
}
const auditDetail = action === "user.password" ? "{}" : JSON.stringify(body);
writeAudit(auditPath, actor, action, target, auditDetail);
return result;
}
return {summary, mutate, accountSummary, accountMutate};
}