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
+142
View File
@@ -0,0 +1,142 @@
# Xiteng Site component labels
`xiteng.site` does not contain a hardcoded service list. A Docker container opts one or more
components into the public catalog with labels under this namespace:
```yaml
labels:
- "xiteng.site.component.example.enabled=true"
- "xiteng.site.component.example.name=Example"
- "xiteng.site.component.example.description=What this component does."
- "xiteng.site.component.example.section=services"
- "xiteng.site.component.example.category=开发与协作"
- "xiteng.site.component.example.url=https://example.xiteng.site"
- "xiteng.site.component.example.endpoint=example.xiteng.site:443"
- "xiteng.site.component.example.access=sso"
- "xiteng.site.component.example.access-label=需要 Authentik"
- "xiteng.site.component.example.icon=EX"
- "xiteng.site.component.example.icon-url=https://example.xiteng.site/favicon.svg"
- "xiteng.site.component.example.accent=blue"
- "xiteng.site.component.example.order=100"
- "xiteng.site.component.example.navigation=new-tab"
- "xiteng.site.component.example.portal-link=native"
- "xiteng.site.component.example.monitor.enabled=true"
- "xiteng.site.component.example.monitor.url=http://example:8080/healthz"
```
## Schema
| Field | Required | Values / behavior |
|---|---:|---|
| `enabled` | yes | Only the exact value `true` publishes the component. |
| `name` | yes | Public display name. |
| `description` | recommended | Public description; never put secrets here. |
| `section` | yes | `services` or `infrastructure`. |
| `category` | recommended | Dynamic group heading. |
| `url` | no | Only HTTP(S) URLs are accepted. No URL renders a non-clickable card. |
| `endpoint` | no | Public protocol endpoint or connection hint. |
| `access` | yes | Machine-readable mode such as `public`, `sso`, `mixed`, `access-key`, `ssh-key`, `local`, or `internal`. |
| `access-label` | recommended | Human-readable access boundary shown on the card. |
| `icon` | no | Final fallback text or emoji, limited to eight characters. |
| `icon-url` | no | Preferred HTTP(S) icon URL. Without it, the UI tries `/favicon.svg`, `/favicon.ico`, then `/favicon.png` on the component origin before showing `icon`. |
| `accent` | no | `red`, `green`, `yellow`, `blue`, or `ink`. |
| `order` | no | Numeric order inside a section; defaults to `999`. |
| `navigation` | no | `new-tab` (default for URLs), `same-tab`, or `endpoint` (default without a URL). |
| `portal-link` | no | `embedded`, `native`, or `none`; documents how the service returns to the Portal. |
| `monitor.enabled` | no | `true` enables the built-in HTTP GET probe. |
| `monitor.url` | when enabled | Internal HTTP(S) target. It is never returned by the public API. |
| `monitor.interval` | no | Check interval in seconds, default `60`. |
| `monitor.failures` | no | Consecutive failures before `down`, default `3`. |
| `monitor.timeout` | no | Request timeout in seconds, default `10`. |
| `monitor.accept` | no | Accepted HTTP codes, default `200-299`; comma-separated values and ranges are supported. |
The component id (`example` above) must be globally stable. One container may publish multiple
components by using multiple ids, which is useful for services such as SeaweedFS Web and its S3 API.
The registry reads container state and image names from Docker. It returns only the public fields above plus
Compose project/service names, runtime status, sanitized monitor state, response time, last check time, and
24-hour availability. Internal monitor URLs, errors, environment variables, mounts, raw labels, Docker
configuration, and secret values are never returned to the public site container.
## Navigation contract
Components with a public HTTP(S) URL open in a new tab by default with `noopener noreferrer`, leaving the
Portal available in the original tab. Owned or officially customizable applications link back to
`https://xiteng.site/?focus=<component-id>#services`; the Portal clears incompatible filters, scrolls to the
component card, briefly highlights it, and then removes the `focus` query parameter. Protocol endpoints and
internal-only components remain non-clickable. Do not inject navigation into third-party HTML at the proxy.
## Monitoring and lifecycle
The Registry is the only discovery and lifecycle control plane. It uses the stable component id as the
database primary key, so a component can never create multiple monitors. It performs bounded-concurrency
HTTP GET probes and stores state in `site/data/registry.db`:
```text
active → missing → archived → purged
```
- `active`: the component Label is currently present on a Docker container;
- `missing`: the container or Label disappeared, but catalog and monitor history remain for 30 days;
- `archived`: monitoring is paused and the component is hidden from the default public catalog;
- `purged`: an administrator explicitly removes the component and all of its monitoring data.
A component that returns with the same id before purge reuses its existing history. Raw checks are retained
for 30 days; hourly and daily aggregates are retained for 365 days. Response bodies are never stored.
`https://xiteng.site/admin` and `/api/admin/*` are protected by Authentik ForwardAuth and additionally
require the exact username `liooil`; this check is repeated in the site backend and Key Vault. All other
users use `/account` and `/api/account/*`. The account page is the canonical self-service surface for
profile name/email, avatar resolution, password recovery, TOTP, Passkeys, Authentik sessions, and Provider
configuration constrained by the session's `(issuer, sub)` pair. Provider definition, model list, and an
optional credential are saved from one form. Backend credentials are encrypted by Key Vault; Frontend
credentials remain in the current browser's IndexedDB. Either mode can be saved and connectivity-tested in
the same action. The public homepage remains unauthenticated.
The admin page is the canonical control plane for human users, ordinary groups, password setup/recovery,
session revocation, authenticator status/reset, and a group-only application access matrix. The native
Authentik admin UI is hidden. TOTP and Passkey enrollment launches dedicated Authentik setup flows and
returns to `/account`; list, rename, delete, reset, and ownership checks remain in the Portal API. The account
page hashes the normalized email with SHA-256 in the browser, then loads Gravatar and Libravatar directly with
no referrer; a deterministic initials image remains visible while loading and on failure. Avatar bytes never
pass through the Portal backend. The Portal accesses Authentik with a server-only API token and
records every identity mutation in a local JSONL audit; password values are never logged. `liuhome` is
protected and currently contains `liooil` and `ziyue`; all managed non-public applications are restricted
to that group.
The default Authentik identification stage enables WebAuthn conditional UI. A discoverable Passkey can
authenticate directly on `auth.xiteng.site`; Authentik's default flow policies then skip both password and
the later MFA stage. Username/password plus TOTP remains available as a fallback, and new Passkeys are
enrolled with the default `resident_key_requirement=preferred` setup stage.
## Host metrics
The internal `metrics` service publishes the sanitized `/api/metrics` payload used by the device status
cards. It reads host CPU and memory counters from a read-only `/proc` mount, root filesystem capacity
through a read-only bind on the same filesystem, and NVIDIA GPU telemetry through the `utility` driver
capability. The public payload is limited to:
- CPU usage, model, logical core count, and load averages;
- memory and root filesystem used/available/total values;
- GPU model, utilization, VRAM, temperature, and power;
- hostname and collection timestamp.
The metrics container has no Docker socket and no public router. The site server proxies its fixed
internal endpoint as `/api/metrics`; arbitrary host files and commands are not exposed.
## PWA and icons
`favicon.svg` is the source artwork for browser and install icons. The PNG and ICO derivatives live in
`icons/` and at `favicon.ico`. `manifest.webmanifest` enables standalone installation and shortcuts to the
service and infrastructure catalogs. `sw.js` caches only the public page shell and static artwork. Runtime
component/metrics APIs, Authentik paths, and all administration requests always use the network and are
never written to the PWA cache.
Setting `enabled=false`, removing the labels, or removing the container moves the component to `missing`
without changing `index.html` or `app.js`. The old `homepage.*` and `kuma.*` namespaces are no longer read;
new and existing components use only the `xiteng.site.component.*` schema.
Static edge caching is a separate, security-sensitive declaration and is intentionally not part of
the public component schema. A service can opt public, user-independent asset directories into the
shared cache with `xiteng.site.cache.<policy>.*` labels. See `edge-cache/README.md` for the schema;
never apply it to API, admin, callback, tokenized download, HTML, or user-content paths.
+162
View File
@@ -0,0 +1,162 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f0c94b">
<meta name="robots" content="noindex, nofollow">
<title>我的账户 / xiteng.site</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/styles.css">
<script src="/account.js?v=2" defer></script>
</head>
<body class="admin-page">
<header class="topbar">
<nav class="shell nav" aria-label="账户导航">
<a class="brand" href="/">xiteng.site / account</a>
<div class="nav-links">
<a id="account-admin-link" href="/admin" hidden>系统管理</a>
<a href="https://chat.xiteng.site">Chat</a>
<a href="/">公开主页</a>
</div>
</nav>
</header>
<main class="shell admin-main">
<section class="admin-heading">
<p class="eyebrow">PROFILE / SECURITY / KEY VAULT</p>
<h1>我的账户</h1>
<p>管理个人资料、密码、双因素认证、Passkey、登录会话和 AI Provider 凭据。</p>
<p id="account-session" class="admin-session" aria-live="polite">正在验证会话…</p>
</section>
<section class="admin-panel account-profile-panel">
<div class="admin-panel-heading"><div><p class="section-kicker">PROFILE</p><h2>个人资料</h2></div></div>
<div class="account-profile-layout">
<div class="account-avatar-card">
<img id="account-avatar" class="account-avatar" alt="当前用户头像" referrerpolicy="no-referrer">
<strong id="account-avatar-name">正在读取…</strong>
<span id="account-avatar-source" class="admin-help"></span>
<button id="account-avatar-refresh" class="admin-action" type="button">重新解析头像</button>
</div>
<form id="account-profile-form" class="admin-form compact">
<label>用户名<input id="account-username" disabled></label>
<label>显示名<input name="name" maxlength="150" required></label>
<label>邮箱<input name="email" type="email" maxlength="254" autocomplete="email"></label>
<button class="admin-action" type="submit">保存个人资料</button>
</form>
</div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading">
<div><p class="section-kicker">SECURITY</p><h2>登录与安全</h2></div>
<button id="account-security-refresh" class="admin-action" type="button">刷新</button>
</div>
<div class="account-security-grid">
<section class="account-security-card">
<h3>密码</h3>
<p class="admin-help">生成 15 分钟有效的 Authentik 密码设置链接。现有会话不会自动注销。</p>
<button id="account-password" class="admin-action" type="button">修改密码</button>
</section>
<section class="account-security-card">
<div class="account-security-heading"><h3>TOTP 验证器</h3><button id="account-add-totp" class="admin-action" type="button">添加 TOTP</button></div>
<div id="account-totp" class="admin-list"><div class="loading-card">正在读取…</div></div>
</section>
<section class="account-security-card">
<div class="account-security-heading"><h3>Passkey</h3><button id="account-add-passkey" class="admin-action" type="button">添加 Passkey</button></div>
<div id="account-passkeys" class="admin-list"><div class="loading-card">正在读取…</div></div>
</section>
<section class="account-security-card account-sessions-card">
<div class="account-security-heading"><h3>登录会话</h3><button id="account-logout-all" class="admin-action dangerous" type="button">注销全部会话</button></div>
<div id="account-sessions" class="admin-list"><div class="loading-card">正在读取…</div></div>
</section>
</div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading">
<div><p class="section-kicker">BUILT-IN + CUSTOM</p><h2>Provider Registry</h2></div>
<button id="account-refresh" class="admin-action" type="button">刷新</button>
</div>
<div class="admin-notice">只需填写 URL 与 API Key 即可先行探测;测试不会保存 Provider 或凭据。Backend Key 保存到 Key VaultFrontend Key 必须跳转到 Chat 后保存在 <code>chat.xiteng.site</code> 的浏览器存储中。</div>
<form id="account-provider-form" class="admin-form provider-editor-form">
<div class="provider-probe-fields">
<label>Provider URL<input name="baseUrl" type="url" required placeholder="https://api.example.com/v1"></label>
<label>API Key<input name="apiKey" type="password" maxlength="32768" autocomplete="new-password" placeholder="探测使用;Frontend 保存时会跳转到 Chat 重新输入"></label>
<button id="account-provider-test" class="admin-action" type="button">探测 Provider</button>
</div>
<details class="provider-advanced">
<summary>进阶设置</summary>
<div class="provider-advanced-grid">
<label>ID<input name="id" required maxlength="80" placeholder="自动根据域名生成"></label>
<label>名称<input name="name" required maxlength="160" placeholder="自动根据域名生成"></label>
<label>API 协议
<select name="api" required>
<option value="">自动探测</option>
<option value="openai-completions">OpenAI Completions</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic-messages">Anthropic Messages</option>
<option value="google-generative-ai">Google Generative AI</option>
</select>
</label>
<label>连接
<select name="connectionType" required>
<option value="backend">Backend</option>
<option value="frontend">Frontend</option>
</select>
</label>
<label>认证
<select name="authType" required>
<option value="">自动探测</option>
<option value="bearer">Bearer</option>
<option value="header">Header</option>
<option value="none">None</option>
</select>
</label>
<label>认证 Header<input name="authHeader" maxlength="120" placeholder="x-api-key"></label>
<label>默认模型<input name="defaultModel" required maxlength="300" placeholder="探测后可从模型列表选择"></label>
<label>模型发现类型
<select name="discoveryType" required>
<option value="">根据 API 协议自动选择</option>
<option value="openai-models-list">OpenAI Models List</option>
<option value="anthropic-models-list">Anthropic Models List</option>
<option value="google-models-list">Google Models List</option>
</select>
</label>
<label>模型列表 URL<input name="discoveryUrl" type="url" placeholder="自动根据 Provider URL 生成"></label>
<label>Proxy 类型
<select name="proxyType">
<option value="">None</option>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
<option value="relay">Frontend Relay</option>
</select>
</label>
<label>Proxy URL<input name="proxyUrl" placeholder="socks5://proxy:1080" disabled></label>
<fieldset class="provider-credential-fields">
<legend>Proxy 凭据</legend>
<label>Proxy 用户名<input name="proxyUsername" autocomplete="off" disabled></label>
<label>Proxy 密码 / Relay Token<input name="proxyPassword" type="password" autocomplete="new-password" disabled></label>
</fieldset>
</div>
</details>
<div class="admin-actions provider-editor-actions">
<button class="admin-action" type="submit">保存 Provider 与凭据</button>
</div>
<div id="account-provider-test-result" class="provider-test-result" aria-live="polite"></div>
</form>
<div id="account-providers" class="admin-list"><div class="loading-card">正在读取 Provider…</div></div>
<div class="admin-panel-heading provider-credentials-heading"><div><p class="section-kicker">SAVED BACKEND SECRETS</p><h3>已保存的 Backend 凭据</h3></div></div>
<div id="account-credentials" class="admin-list"><div class="loading-card">正在读取凭据元数据…</div></div>
</section>
<section class="admin-panel">
<div class="admin-panel-heading"><div><p class="section-kicker">AUDIT</p><h2>我的审计记录</h2></div></div>
<div id="account-audit" class="admin-audit"></div>
</section>
</main>
</body>
</html>
+789
View File
@@ -0,0 +1,789 @@
const state = {session: null, identity: null, providers: [], credentials: [], audit: [], busy: new Set()};
function element(tagName, className, text) {
const node = document.createElement(tagName);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function formatTime(value) {
if (!value) return "—";
return new Date(value).toLocaleString("zh-CN", {month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit"});
}
function avatarInitials(profile) {
const source = String(profile.name || profile.username || "U").trim() || "U";
const parts = source.split(/\s+/).filter(Boolean);
return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)[0]}` : [...source].slice(0, 2).join("")).toUpperCase();
}
function avatarPlaceholder(profile) {
const initials = avatarInitials(profile);
const source = String(profile.username || profile.name || initials);
let hash = 0;
for (const character of source) hash = ((hash << 5) - hash + character.codePointAt(0)) | 0;
const hue = Math.abs(hash) % 360;
const escaped = initials.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="32" fill="hsl(${hue} 58% 48%)"/><text x="128" y="145" text-anchor="middle" font-family="system-ui,sans-serif" font-size="82" font-weight="800" fill="white">${escaped}</text></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
async function avatarHash(email) {
const input = new TextEncoder().encode(String(email || "").trim().toLowerCase());
const digest = await crypto.subtle.digest("SHA-256", input);
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function loadAccountAvatar(profile, refresh = false) {
const avatar = document.getElementById("account-avatar");
const requestId = String(Number(avatar.dataset.requestId || "0") + 1);
avatar.dataset.requestId = requestId;
avatar.dataset.loading = "true";
avatar.src = avatarPlaceholder(profile);
if (!String(profile.email || "").trim()) {
avatar.dataset.loading = "false";
return;
}
const hash = await avatarHash(profile.email);
if (avatar.dataset.requestId !== requestId) return;
const cacheBuster = refresh ? `&v=${Date.now()}` : "";
const sources = [
`https://www.gravatar.com/avatar/${hash}?d=404&s=256${cacheBuster}`,
`https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256${cacheBuster}`
];
const trySource = (sourceIndex) => {
if (avatar.dataset.requestId !== requestId) return;
if (sourceIndex >= sources.length) {
avatar.dataset.loading = "false";
avatar.avatarLoader = null;
return;
}
const image = new Image();
image.referrerPolicy = "no-referrer";
avatar.avatarLoader = image;
const timeout = window.setTimeout(() => {
image.onload = null;
image.onerror = null;
trySource(sourceIndex + 1);
}, 5000);
image.onload = () => {
window.clearTimeout(timeout);
if (avatar.dataset.requestId !== requestId) return;
avatar.src = image.src;
avatar.dataset.loading = "false";
if (avatar.avatarLoader === image) avatar.avatarLoader = null;
};
image.onerror = () => {
window.clearTimeout(timeout);
trySource(sourceIndex + 1);
};
image.src = sources[sourceIndex];
};
trySource(0);
}
async function request(path, options = {}) {
const response = await fetch(path, {
cache: "no-store",
...options,
headers: {"Accept": "application/json", ...(options.body ? {"Content-Type": "application/json"} : {})}
});
const text = await response.text();
let payload = {};
try { payload = text ? JSON.parse(text) : {}; } catch { payload = {error: text}; }
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
return payload;
}
function actionButton(label, handler, dangerous = false) {
const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label);
button.type = "button";
button.addEventListener("click", handler);
return button;
}
function pill(label, tone = "") {
return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label);
}
async function withBusy(key, operation) {
if (state.busy.has(key)) return;
state.busy.add(key);
try { await operation(); } catch (error) { window.alert(`操作失败:${error.message}`); } finally { state.busy.delete(key); }
}
async function loadSession() {
const payload = await request("/api/account/session");
state.session = payload.identity;
document.getElementById("account-session").textContent = `${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`;
document.getElementById("account-admin-link").hidden = !payload.identity.admin;
}
function securityRecord(title, detail, actions = []) {
const record = element("article", "admin-record account-security-record");
const heading = element("div", "admin-record-heading");
heading.append(element("strong", "", title));
const buttons = element("div", "admin-actions");
actions.forEach((action) => buttons.append(action));
heading.append(buttons);
record.append(heading, element("p", "admin-help", detail));
return record;
}
async function mutateAccount(path, body = {}) {
return request(path, {method: "POST", body: JSON.stringify(body)});
}
function renderSecurityList(id, entries, emptyText, renderer) {
const container = document.getElementById(id);
container.replaceChildren();
if (!entries.length) container.append(element("div", "empty-state", emptyText));
entries.forEach((entry) => container.append(renderer(entry)));
}
function renderAccount() {
const {profile, security} = state.identity;
const form = document.getElementById("account-profile-form");
form.elements.namedItem("name").value = profile.name || profile.username;
form.elements.namedItem("email").value = profile.email || "";
document.getElementById("account-username").value = profile.username;
document.getElementById("account-avatar-name").textContent = profile.name || profile.username;
document.getElementById("account-avatar-source").textContent = "浏览器直连 Gravatar → Libravatar → 本地字母";
loadAccountAvatar(profile);
renderSecurityList("account-totp", security.totp, "尚未配置 TOTP。", (device) => securityRecord(
device.name,
"Authenticator TOTP",
[
actionButton("重命名", async () => {
const name = window.prompt("TOTP 设备名称", device.name);
if (name) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/rename`, {name}); await loadAccount(); });
}),
actionButton("删除", async () => {
if (window.confirm(`删除 TOTP 设备 ${device.name}`)) await withBusy(`totp-${device.id}`, async () => { await mutateAccount(`/api/account/security/totp/${device.id}/delete`); await loadAccount(); });
}, true)
]
));
renderSecurityList("account-passkeys", security.passkeys, "尚未注册 Passkey。", (device) => securityRecord(
device.name,
`${device.deviceType || "WebAuthn"} · 注册 ${formatTime(device.createdAt)} · ${device.aaguid || "AAGUID 未知"}`,
[
actionButton("重命名", async () => {
const name = window.prompt("Passkey 名称", device.name);
if (name) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/rename`, {name}); await loadAccount(); });
}),
actionButton("删除", async () => {
if (window.confirm(`删除 Passkey ${device.name}?请确认仍有密码或其他 Passkey 可用。`)) await withBusy(`passkey-${device.id}`, async () => { await mutateAccount(`/api/account/security/passkeys/${device.id}/delete`); await loadAccount(); });
}, true)
]
));
renderSecurityList("account-sessions", security.sessions, "当前没有可管理的登录会话。", (session) => securityRecord(
session.current ? "当前会话" : (session.userAgent || "登录会话"),
`${session.ip || "IP 未知"} · 最近使用 ${formatTime(session.lastUsed)} · 到期 ${formatTime(session.expires)}`,
[actionButton("注销", async () => {
if (!window.confirm(session.current ? "注销当前会话?" : "注销此会话?")) return;
await withBusy(`session-${session.id}`, async () => {
await mutateAccount(`/api/account/security/sessions/${session.id}/delete`);
if (session.current) window.location.reload(); else await loadAccount();
});
}, true)]
));
}
async function loadAccount() {
state.identity = await request("/api/account/identity");
renderAccount();
}
const featuredProviderGroups = [
{title: "SOTA", ids: ["openai", "anthropic"]},
{title: "Proxy", ids: ["openrouter", "rust.cat"]},
{title: "本地模型", ids: ["llama.cpp", "ollama"]},
{title: "其他常用", ids: ["deepseek", "minimax", "moonshot", "zai"]}
];
function fillProviderForm(provider) {
const form = document.getElementById("account-provider-form");
const fields = form.elements;
form.dataset.providerId = provider.id;
fields.namedItem("id").value = provider.id;
fields.namedItem("name").value = provider.name;
fields.namedItem("api").value = provider.api;
fields.namedItem("connectionType").value = provider.connection.type;
fields.namedItem("baseUrl").value = provider.connection.baseUrl;
fields.namedItem("authType").value = provider.auth.type;
fields.namedItem("authHeader").value = provider.auth.header || "";
fields.namedItem("proxyType").value = provider.connection.proxy?.type || "";
fields.namedItem("proxyUrl").value = provider.connection.proxy?.url || "";
fields.namedItem("defaultModel").value = provider.defaultModel || "";
fields.namedItem("discoveryType").value = provider.discovery?.type || "";
fields.namedItem("discoveryUrl").value = provider.discovery?.url || "";
fields.namedItem("apiKey").value = "";
fields.namedItem("proxyUsername").value = "";
fields.namedItem("proxyPassword").value = "";
const hasCredential = provider.connection.type === "backend"
&& provider.credentials.some((credential) => credential.name === "default");
fields.namedItem("apiKey").placeholder = provider.connection.type === "frontend"
? "仅用于探测;Frontend Key 需在 Chat 中保存"
: hasCredential ? "已保存;留空保持原凭据" : "可选";
syncProviderConnectionFields();
form.querySelector(".provider-advanced").open = true;
form.scrollIntoView({behavior: "smooth", block: "start"});
fields.namedItem("name").focus({preventScroll: true});
}
function providerGroup(title, providers) {
const section = element("section", "provider-group");
section.append(element("h3", "provider-group-title", title));
const list = element("div", "admin-list");
providers.forEach((provider) => list.append(renderProvider(provider)));
section.append(list);
return section;
}
function providerHasCredential(provider, name = "default") {
return provider.connection.type === "backend"
&& provider.credentials.some((credential) => credential.name === name);
}
function providerTestEndpoint(provider) {
return provider.discovery.url;
}
function inferredProviderSettings(baseUrl, api = "", authType = "", authHeader = "") {
const url = new URL(baseUrl);
const hostname = url.hostname.toLowerCase();
const inferredApi = api || (hostname.includes("anthropic")
? "anthropic-messages"
: hostname.includes("googleapis") ? "google-generative-ai" : "openai-completions");
const inferredAuth = authType || (inferredApi === "anthropic-messages" || inferredApi === "google-generative-ai" ? "header" : "bearer");
const inferredHeader = inferredAuth === "header"
? authHeader || (inferredApi === "google-generative-ai" ? "x-goog-api-key" : "x-api-key")
: "";
const inferredId = hostname.replace(/^api\./, "").split(".")[0].replace(/[^a-z0-9._-]+/g, "-") || "custom-provider";
return {api: inferredApi, authType: inferredAuth, authHeader: inferredHeader, id: inferredId, name: hostname};
}
function inferredProviderDiscovery(baseUrl, api, type = "", discoveryUrl = "") {
const discoveryType = type || (api === "anthropic-messages"
? "anthropic-models-list"
: api === "google-generative-ai" ? "google-models-list" : "openai-models-list");
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
const url = discoveryUrl || (discoveryType === "anthropic-models-list"
? `${normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`}/models?limit=200`
: discoveryType === "google-models-list" ? `${normalizedBaseUrl}/models?pageSize=200` : `${normalizedBaseUrl}/models`);
return {type: discoveryType, url};
}
function providerAccountEndpoints(provider) {
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
const url = new URL(baseUrl);
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
if (url.hostname === "api.openai.com" || ["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
return [];
}
function providerHeaders(provider, secret) {
const headers = new Headers({"Accept": "application/json"});
Object.entries(provider.headers || {}).forEach(([name, value]) => headers.set(name, value));
Object.entries(secret.provider?.headers || {}).forEach(([name, value]) => headers.set(name, value));
const apiKey = secret.provider?.apiKey || "";
if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey);
if (provider.api === "anthropic-messages" && !headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01");
return headers;
}
function modelResults(payload) {
const source = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload?.models) ? payload.models : [];
return source.slice(0, 300).map((model) => {
const rawId = model?.id || model?.name || model?.model;
if (typeof rawId !== "string" || !rawId.trim()) return null;
const id = rawId.replace(/^models\//, "");
const pricing = Object.fromEntries(Object.entries(model).filter(([key]) => /price|pricing|cost|rate|token/i.test(key)));
return {id, name: model.displayName || model.name?.replace?.(/^models\//, "") || id, ...(Object.keys(pricing).length ? {pricing} : {})};
}).filter(Boolean);
}
function providerAccountMetadata(payload) {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const entries = Object.entries(payload).filter(([key]) => /balance|credit|quota|usage|limit|billing|currency/i.test(key));
return entries.length ? Object.fromEntries(entries) : null;
}
function providerRateLimits(response) {
const entries = [...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name));
return entries.length ? Object.fromEntries(entries) : null;
}
function renderProviderTestResult(result) {
const target = document.getElementById("account-provider-test-result");
target.replaceChildren();
const heading = element("div", "provider-test-heading");
heading.append(
element("strong", "", `连接成功 · HTTP ${result.status} · ${result.latencyMs}ms`),
element("code", "", result.endpoint)
);
target.append(heading);
const models = Array.isArray(result.models) ? result.models : [];
const modelSection = element("section", "provider-test-section");
modelSection.append(element("h4", "", `发现模型(${result.modelCount ?? models.length}`));
if (!models.length) {
modelSection.append(element("p", "admin-help", "Provider 未返回可识别的模型列表。"));
} else {
const modelRow = (model) => {
const row = element("div", "provider-test-model");
const identity = element("div");
identity.append(element("strong", "", model.name || model.id), element("code", "", model.id));
if (model.pricing) identity.append(element("small", "", `费率:${JSON.stringify(model.pricing)}`));
row.append(identity, actionButton("设为默认", () => {
document.getElementById("account-provider-form").elements.namedItem("defaultModel").value = model.id;
}));
return row;
};
const displayedModels = models.slice(0, 100);
const initialModels = displayedModels.slice(0, window.matchMedia("(max-width: 680px)").matches ? 5 : 12);
const list = element("div", "provider-test-models");
initialModels.forEach((model) => list.append(modelRow(model)));
modelSection.append(list);
if (displayedModels.length > initialModels.length) {
const more = element("details", "provider-test-more");
more.append(element("summary", "admin-action", `展开其余 ${displayedModels.length - initialModels.length} 个模型`));
const moreList = element("div", "provider-test-models");
displayedModels.slice(initialModels.length).forEach((model) => moreList.append(modelRow(model)));
more.append(moreList);
modelSection.append(more);
}
if (models.length > 100) modelSection.append(element("p", "admin-help", `仅展示前 100 个模型;共发现 ${models.length} 个。`));
}
target.append(modelSection);
const metadata = [
["账户 / 余额", result.account],
["账户探测", result.accountProbe],
["限流信息", result.rateLimits]
].filter((entry) => entry[1]);
if (metadata.length) {
const details = element("section", "provider-test-section");
details.append(element("h4", "", "账户与费率信息"));
metadata.forEach(([label, value]) => {
const block = element("div", "provider-test-metadata");
block.append(element("strong", "", label), element("pre", "", JSON.stringify(value, null, 2)));
details.append(block);
});
target.append(details);
}
target.className = "provider-test-result success";
}
function renderProviderTestError(error) {
const target = document.getElementById("account-provider-test-result");
target.className = "provider-test-result error-state";
target.textContent = `测试失败:${error.message}`;
}
function frontendProviderFetch(provider, secret) {
if (!provider.connection.proxy) return fetch;
if (provider.connection.proxy.type !== "relay") throw new Error(`不支持的 Frontend Proxy${provider.connection.proxy.type}`);
return (url, options = {}) => fetch(provider.connection.proxy.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(secret.proxy?.token ? {"Authorization": `Bearer ${secret.proxy.token}`} : {})
},
body: JSON.stringify({
url: String(url),
method: options.method || "GET",
headers: Object.fromEntries(new Headers(options.headers).entries()),
body: typeof options.body === "string" ? options.body : null
}),
signal: options.signal
});
}
async function testProviderConnection(provider, credentialName = "default", secretOverride = null) {
if (provider.connection.type === "backend") {
return request("/api/account/provider-test", {
method: "POST",
body: JSON.stringify({providerId: provider.id, credentialName})
});
}
const secret = secretOverride || {};
if (provider.auth.type !== "none" && !secretOverride) throw new Error("请在 Chat 中配置此 Frontend Provider 的本地 Key");
const endpoint = providerTestEndpoint(provider);
const providerFetch = frontendProviderFetch(provider, secret);
const headers = providerHeaders(provider, secret);
const startedAt = performance.now();
const response = await providerFetch(endpoint, {
method: "GET",
headers,
signal: AbortSignal.timeout(15000)
});
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
const text = await response.text();
if (!response.ok) throw new Error(text.slice(0, 1000) || `Provider HTTP ${response.status}`);
let payload = {};
try { payload = text ? JSON.parse(text) : {}; } catch { payload = {preview: text.slice(0, 1000)}; }
const models = modelResults(payload);
let accountProbe = null;
for (const accountEndpoint of providerAccountEndpoints(provider)) {
try {
const accountResponse = await providerFetch(accountEndpoint, {method: "GET", headers, signal: AbortSignal.timeout(5000)});
if (!accountResponse.ok) continue;
accountProbe = {endpoint: accountEndpoint, status: accountResponse.status, data: await accountResponse.json()};
break;
} catch {
// Account metadata is optional and must not fail a successful model probe.
}
}
return {
ok: true,
status: response.status,
latencyMs,
endpoint,
modelCount: models.length,
models,
account: providerAccountMetadata(payload),
accountProbe,
rateLimits: providerRateLimits(response)
};
}
function renderProvider(provider) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const identity = element("div");
identity.append(element("h3", "", provider.name), element("code", "", provider.id));
const badges = element("div", "admin-component-status");
const credentialLabel = provider.connection.type === "frontend"
? provider.auth.type === "none" ? "无需 Key" : "由 Chat 管理 Key"
: providerHasCredential(provider) ? "凭据已设置" : "无凭据";
badges.append(
pill(provider.builtin ? "内置" : "Custom", provider.builtin ? "" : "monitor-up"),
pill(provider.connection.type === "frontend" ? "Frontend" : "Backend"),
pill(credentialLabel, provider.connection.type === "frontend" || providerHasCredential(provider) ? "monitor-up" : ""),
pill(provider.api)
);
heading.append(identity, badges);
const proxy = provider.connection.proxy ? `${provider.connection.proxy.type} · ${provider.connection.proxy.url}` : "无";
const details = element("p", "admin-help", `${provider.connection.baseUrl} · Proxy ${proxy} · 默认模型 ${provider.defaultModel || "未设置"}`);
const actions = element("div", "admin-actions");
actions.append(actionButton(provider.builtin ? "复制并配置" : "编辑 Provider 与凭据", () => fillProviderForm(provider)));
actions.append(actionButton("测试连通性", () => withBusy(`provider-test-${provider.id}`, async () => {
try {
renderProviderTestResult(await testProviderConnection(provider));
document.getElementById("account-provider-test-result").scrollIntoView({behavior: "smooth", block: "nearest"});
} catch (error) {
renderProviderTestError(error);
throw error;
}
})));
if (provider.connection.type === "frontend") {
actions.append(actionButton(provider.auth.type === "none" ? "在 Chat 中配置端点" : "在 Chat 中配置本地 Key(需 CORS / Relay", () => {
window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(provider.id)}`;
}));
}
if (!provider.builtin) {
actions.append(actionButton("删除 Custom", async () => {
if (!window.confirm(`删除 Custom Provider ${provider.id}`)) return;
await withBusy(`provider-${provider.id}`, async () => {
await request(`/api/account/vault/providers/${provider.id}/delete`, {method: "POST", body: "{}"});
await loadVault();
});
}, true));
}
record.append(heading, details, actions);
return record;
}
function renderCredential(credential) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const identity = element("div");
identity.append(element("h3", "", credential.providerId), element("code", "", credential.id));
const badges = element("div", "admin-component-status");
badges.append(pill(`sha256:${credential.fingerprint}`));
heading.append(identity, badges);
const meta = element("p", "admin-help", `创建 ${formatTime(credential.createdAt)} · 更新 ${formatTime(credential.updatedAt)} · 最近读取 ${formatTime(credential.lastAccessedAt)}`);
const actions = element("div", "admin-actions");
actions.append(actionButton("校验加密完整性", () => runCredentialAction(credential, "verify")));
actions.append(actionButton("编辑 Provider 与凭据", () => {
const provider = state.providers.find((item) => item.id === credential.providerId);
if (provider) fillProviderForm(provider);
}));
actions.append(actionButton("删除", async () => {
if (window.confirm(`永久删除 ${credential.providerId} 的凭据?`)) await runCredentialAction(credential, "delete");
}, true));
record.append(heading, meta, actions);
return record;
}
function renderVault() {
const providers = document.getElementById("account-providers");
providers.replaceChildren();
const customProviders = state.providers.filter((provider) => !provider.builtin);
if (customProviders.length) providers.append(providerGroup("我的 Custom Provider", customProviders));
const featuredIds = new Set(featuredProviderGroups.flatMap((group) => group.ids));
for (const group of featuredProviderGroups) {
const entries = group.ids.map((id) => state.providers.find((provider) => provider.builtin && provider.id === id)).filter(Boolean);
if (entries.length) providers.append(providerGroup(group.title, entries));
}
const moreProviders = state.providers.filter((provider) => provider.builtin && !featuredIds.has(provider.id));
if (moreProviders.length) {
const more = element("details", "provider-more");
more.append(element("summary", "admin-action", `更多内置 Provider${moreProviders.length}`));
const list = element("div", "admin-list provider-more-list");
moreProviders.forEach((provider) => list.append(renderProvider(provider)));
more.append(list);
providers.append(more);
}
const credentials = document.getElementById("account-credentials");
credentials.replaceChildren();
if (state.credentials.length) state.credentials.forEach((credential) => credentials.append(renderCredential(credential)));
else credentials.append(element("div", "empty-state", "尚未保存 Backend Credential。"));
const audit = document.getElementById("account-audit");
audit.replaceChildren();
if (!state.audit.length) audit.append(element("div", "empty-state", "暂无审计事件。"));
for (const event of state.audit) {
const row = element("div", "audit-row");
row.append(element("time", "", formatTime(event.createdAt)), element("code", "", event.actorUsername), element("strong", "", event.action), element("span", "", event.detail || event.targetId || "—"), pill(event.result, event.result === "success" ? "monitor-up" : "monitor-down"));
audit.append(row);
}
}
async function loadVault() {
try {
const [providers, credentials, audit] = await Promise.all([
request("/api/account/vault/providers"),
request("/api/account/vault/credentials"),
request("/api/account/vault/audit")
]);
state.providers = providers.providers || [];
state.credentials = credentials.credentials || [];
state.audit = audit.events || [];
renderVault();
} catch (error) {
document.getElementById("account-providers").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`));
}
}
function buildSecret(apiKey, proxyUsername, proxyPassword, connectionType = "backend") {
return {
provider: {apiKey},
...(proxyUsername || proxyPassword ? {
proxy: connectionType === "frontend"
? {token: proxyPassword}
: {username: proxyUsername, password: proxyPassword}
} : {})
};
}
function providerFormValue(form) {
const data = new FormData(form);
const connectionType = String(data.get("connectionType") || "backend");
const proxyType = String(data.get("proxyType") || "");
const baseUrl = String(data.get("baseUrl") || "").trim();
const inferred = inferredProviderSettings(baseUrl, String(data.get("api") || ""), String(data.get("authType") || ""), String(data.get("authHeader") || ""));
const discovery = inferredProviderDiscovery(baseUrl, inferred.api, String(data.get("discoveryType") || ""), String(data.get("discoveryUrl") || ""));
const providerId = String(data.get("id") || inferred.id).trim().toLowerCase();
const apiKey = String(data.get("apiKey") || "");
const proxyUsername = proxyType ? String(data.get("proxyUsername") || "") : "";
const proxyPassword = proxyType ? String(data.get("proxyPassword") || "") : "";
return {
providerId,
connectionType,
apiKey,
proxyUsername,
proxyPassword,
hasSecretInput: Boolean(apiKey || proxyUsername || proxyPassword),
secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType),
provider: {
id: providerId,
name: String(data.get("name") || inferred.name).trim(),
api: inferred.api,
connection: {
type: connectionType,
baseUrl,
proxy: proxyType ? {type: proxyType, url: data.get("proxyUrl")} : null
},
auth: {
type: inferred.authType,
...(inferred.authType === "header" ? {header: inferred.authHeader} : {})
},
headers: {},
defaultModel: String(data.get("defaultModel") || "probe-model"),
discovery,
builtin: false,
credentials: [],
credentialState: connectionType === "frontend" ? "local" : "missing"
}
};
}
function applyProviderDetection(result) {
const form = document.getElementById("account-provider-form");
const fields = form.elements;
const detected = result.detected || {};
if (!fields.namedItem("id").value) fields.namedItem("id").value = detected.id || "";
if (!fields.namedItem("name").value) fields.namedItem("name").value = detected.name || "";
if (!fields.namedItem("api").value) fields.namedItem("api").value = detected.api || "";
if (!fields.namedItem("authType").value) fields.namedItem("authType").value = detected.auth?.type || "";
if (!fields.namedItem("authHeader").value) fields.namedItem("authHeader").value = detected.auth?.header || "";
if (!fields.namedItem("discoveryType").value) fields.namedItem("discoveryType").value = detected.discovery?.type || "";
if (!fields.namedItem("discoveryUrl").value) fields.namedItem("discoveryUrl").value = detected.discovery?.url || "";
if (!fields.namedItem("defaultModel").value && result.models?.[0]?.id) fields.namedItem("defaultModel").value = result.models[0].id;
syncProviderConnectionFields();
}
document.getElementById("account-provider-test").addEventListener("click", async () => {
const form = document.getElementById("account-provider-form");
const baseUrl = form.elements.namedItem("baseUrl");
if (!baseUrl.reportValidity()) return;
const value = providerFormValue(form);
await withBusy("provider-draft-test", async () => {
try {
const result = value.connectionType === "backend"
? await request("/api/account/provider-test", {
method: "POST",
body: JSON.stringify({provider: value.provider, secret: value.secret})
})
: await testProviderConnection(value.provider, "default", value.hasSecretInput ? value.secret : null);
result.detected ||= {
id: value.provider.id,
name: value.provider.name,
api: value.provider.api,
auth: value.provider.auth,
connection: value.provider.connection,
discovery: value.provider.discovery
};
applyProviderDetection(result);
renderProviderTestResult(result);
} catch (error) {
renderProviderTestError(error);
throw error;
}
});
});
async function runCredentialAction(credential, action, body = {}) {
await withBusy(`credential-${credential.id}`, async () => {
const payload = await request(`/api/account/vault/credentials/${credential.id}/${action}`, {method: "POST", body: JSON.stringify(body)});
if (action === "verify") window.alert(payload.verified ? "密文完整。" : "密文校验失败,请立即替换或删除。");
await loadVault();
});
}
document.getElementById("account-refresh").addEventListener("click", loadVault);
document.getElementById("account-provider-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const value = providerFormValue(form);
const {
providerId,
connectionType,
apiKey,
proxyUsername,
proxyPassword,
hasSecretInput,
provider: payload
} = value;
await withBusy("provider-save", async () => {
await request("/api/account/vault/providers", {method: "POST", body: JSON.stringify(payload)});
if (hasSecretInput && connectionType === "backend") {
const existing = state.credentials.find((credential) => credential.providerId === providerId && credential.name === "default")
|| state.credentials.find((credential) => credential.providerId === providerId);
if (existing) {
await request(`/api/account/vault/credentials/${existing.id}/replace`, {
method: "POST",
body: JSON.stringify({secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)})
});
} else {
await request("/api/account/vault/credentials", {
method: "POST",
body: JSON.stringify({providerId, name: "default", secret: buildSecret(apiKey, proxyUsername, proxyPassword, connectionType)})
});
}
} else if (connectionType === "frontend") {
window.location.href = `https://chat.xiteng.site/?configure=${encodeURIComponent(providerId)}`;
return;
}
await loadVault();
form.reset();
form.querySelector(".provider-advanced").open = false;
syncProviderConnectionFields();
delete form.dataset.providerId;
});
});
function syncProviderConnectionFields() {
const form = document.getElementById("account-provider-form");
const fields = form.elements;
const frontend = fields.namedItem("connectionType").value === "frontend";
const proxyType = fields.namedItem("proxyType");
for (const option of proxyType.options) {
option.hidden = Boolean(option.value) && (frontend ? option.value !== "relay" : option.value === "relay");
}
if (proxyType.selectedOptions[0]?.hidden) proxyType.value = "";
const proxyConfigured = Boolean(proxyType.value);
fields.namedItem("proxyUrl").required = proxyConfigured;
fields.namedItem("proxyUrl").disabled = !proxyConfigured;
fields.namedItem("proxyUsername").disabled = !proxyConfigured || frontend;
const headerAuth = fields.namedItem("authType").value === "header";
fields.namedItem("authHeader").disabled = !headerAuth;
fields.namedItem("authHeader").required = headerAuth;
fields.namedItem("proxyPassword").disabled = !proxyConfigured;
fields.namedItem("proxyPassword").placeholder = frontend ? "Relay Token(可选)" : "Proxy 密码(可选)";
}
const providerForm = document.getElementById("account-provider-form");
providerForm.elements.namedItem("connectionType").addEventListener("change", syncProviderConnectionFields);
providerForm.elements.namedItem("proxyType").addEventListener("change", syncProviderConnectionFields);
providerForm.elements.namedItem("authType").addEventListener("change", syncProviderConnectionFields);
syncProviderConnectionFields();
document.getElementById("account-security-refresh").addEventListener("click", () => withBusy("account-refresh", loadAccount));
document.getElementById("account-profile-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
await withBusy("profile", async () => {
await mutateAccount("/api/account/profile", {name: data.get("name"), email: data.get("email")});
await loadAccount();
});
});
document.getElementById("account-avatar-refresh").addEventListener("click", () => {
loadAccountAvatar(state.identity.profile, true);
});
document.getElementById("account-password").addEventListener("click", () => withBusy("password", async () => {
const payload = await mutateAccount("/api/account/security/password");
window.prompt("15 分钟内有效的密码设置链接", payload.result.link);
}));
document.getElementById("account-add-totp").addEventListener("click", () => window.location.assign(state.identity.security.totpSetupUrl));
document.getElementById("account-add-passkey").addEventListener("click", () => window.location.assign(state.identity.security.passkeySetupUrl));
document.getElementById("account-logout-all").addEventListener("click", async () => {
if (!window.confirm("注销全部 Authentik 会话?当前页面也会退出。")) return;
await withBusy("sessions-all", async () => {
await mutateAccount("/api/account/security/sessions/all");
window.location.reload();
});
});
async function initialize() {
try { await loadSession(); await Promise.all([loadAccount(), loadVault()]); } catch (error) { document.getElementById("account-session").textContent = `会话不可用:${error.message}`; }
}
document.getElementById("account-avatar").src = avatarPlaceholder({name: "U", username: "user"});
initialize();
+144
View File
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f0c94b">
<meta name="robots" content="noindex, nofollow">
<title>Portal 管理 / xiteng.site</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/styles.css">
<script src="/admin.js" defer></script>
</head>
<body class="admin-page">
<header class="topbar">
<nav class="shell nav" aria-label="管理导航">
<a class="brand" href="/">liooil / xiteng.site</a>
<div class="nav-links">
<a href="#identity">身份</a>
<a href="#vault">Key Vault</a>
<a href="#components">组件</a>
<a href="/account">我的凭据</a>
<a href="/">公开主页</a>
</div>
</nav>
</header>
<main class="shell admin-main">
<section class="admin-heading">
<p class="eyebrow">ADMIN CONTROL PLANE</p>
<h1>身份、凭据与组件</h1>
<p>
Authentik 是唯一身份系统。只有用户名严格等于 <code>liooil</code> 的会话可以访问本页;
Key Vault 独立负责 Backend Credential,管理页不会保存或回显密钥明文。
</p>
<p id="admin-session" class="admin-session" aria-live="polite">正在验证管理员会话…</p>
</section>
<section class="admin-summary" aria-label="管理摘要">
<div><strong id="admin-users"></strong><span>家庭用户</span></div>
<div><strong id="admin-credentials"></strong><span>Vault 凭据</span></div>
<div><strong id="admin-total"></strong><span>动态组件</span></div>
<div><strong id="admin-down"></strong><span>不可用组件</span></div>
</section>
<section id="identity" class="admin-panel">
<div class="admin-panel-heading">
<div>
<p class="section-kicker">AUTHENTIK / IDENTITY</p>
<h2>用户与访问策略</h2>
</div>
<button id="identity-refresh" class="admin-action" type="button">刷新</button>
</div>
<div id="identity-status" class="admin-notice" aria-live="polite">正在连接 Authentik…</div>
<form id="identity-search-form" class="admin-form admin-form-inline compact">
<label>搜索<input name="search" maxlength="150" placeholder="用户名、显示名或邮箱"></label>
<button class="admin-action" type="submit">搜索</button>
</form>
<div class="admin-form-grid">
<form id="user-create-form" class="admin-form">
<h3>新建普通用户</h3>
<label>用户名<input name="username" required maxlength="150" autocomplete="off"></label>
<label>显示名<input name="name" required maxlength="160" autocomplete="off"></label>
<label>邮箱<input name="email" type="email" maxlength="254" autocomplete="off"></label>
<button class="admin-action" type="submit">创建并加入 liuhome</button>
</form>
<form id="group-create-form" class="admin-form">
<h3>新建用户组</h3>
<label>名称<input name="name" required maxlength="160" autocomplete="off"></label>
<button class="admin-action" type="submit">创建用户组</button>
<p><code>liuhome</code> 是家庭基础组,不能重命名或删除。</p>
</form>
</div>
<div class="admin-subsection">
<h3>用户</h3>
<div id="identity-users" class="admin-list"><div class="loading-card">正在读取用户…</div></div>
</div>
<div class="admin-subsection">
<h3>用户组</h3>
<div id="identity-groups" class="admin-list"><div class="loading-card">正在读取用户组…</div></div>
</div>
<div class="admin-subsection">
<h3>非开放服务权限矩阵</h3>
<p class="admin-help">每个服务只允许所选用户组;当前统一使用 <code>liuhome</code></p>
<div id="identity-applications" class="admin-list"><div class="loading-card">正在读取应用…</div></div>
</div>
<div class="admin-subsection">
<h3>身份操作审计</h3>
<div id="identity-audit" class="admin-audit"></div>
</div>
</section>
<section id="vault" class="admin-panel">
<div class="admin-panel-heading">
<div>
<p class="section-kicker">KEY VAULT</p>
<h2>Backend Provider 凭据</h2>
</div>
<button id="vault-refresh" class="admin-action" type="button">刷新</button>
</div>
<div class="admin-notice">
管理员只能管理 Backend CredentialFrontend Credential 始终保留在用户浏览器中。
</div>
<form id="credential-create-form" class="admin-form admin-form-inline">
<label>所有者<input value="当前 Authentik 管理员会话" disabled></label>
<label>Provider<select name="providerId" id="admin-credential-provider" required></select></label>
<label>名称<input name="name" required maxlength="120" value="default"></label>
<label>API Key<input name="apiKey" type="password" required maxlength="32768" autocomplete="new-password"></label>
<button class="admin-action" type="submit">加密保存</button>
</form>
<div id="vault-credentials" class="admin-list">
<div class="loading-card">正在读取凭据元数据…</div>
</div>
<div class="admin-subsection">
<h3>审计</h3>
<div id="vault-audit" class="admin-audit"></div>
</div>
</section>
<section id="components" class="admin-panel">
<div class="admin-panel-heading">
<div>
<p class="section-kicker">REGISTRY / MONITORING</p>
<h2>组件生命周期</h2>
</div>
<button id="admin-refresh" class="admin-action" type="button">刷新</button>
</div>
<p id="admin-updated" class="admin-help" aria-live="polite">正在读取 Registry…</p>
<div id="admin-components" class="admin-components" aria-live="polite">
<div class="loading-card">正在读取组件生命周期…</div>
</div>
</section>
</main>
</body>
</html>
+583
View File
@@ -0,0 +1,583 @@
const state = {
session: null,
identity: null,
identitySearch: "",
credentials: [],
providers: [],
audit: [],
components: [],
busy: new Set()
};
const lifecycleLabels = {
active: "当前存在",
missing: "已经消失",
archived: "已经归档"
};
const monitorLabels = {
up: "可用",
down: "不可用",
degraded: "等待重试",
pending: "等待检查",
paused: "已暂停",
unmonitored: "未配置"
};
const knownComponentIcons = {
gitea: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg",
hedgedoc: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg",
hedgedoc2: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg",
"code-server": "https://cdn.simpleicons.org/coder",
chat: "https://xiteng.site/icons/services/chat.svg",
comfyui: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg",
invokeai: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg",
authentik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"authentik-worker": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"authentik-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"seaweedfs-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"authentik-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
"gitea-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
"hedgedoc-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
"authentik-redis": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg",
"cloudflare-tunnel": "https://cdn.simpleicons.org/cloudflare",
traefik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg",
portal: "https://xiteng.site/favicon.svg"
};
function element(tagName, className, text) {
const node = document.createElement(tagName);
if (className) {
node.className = className;
}
if (text !== undefined) {
node.textContent = text;
}
return node;
}
function componentIconSources(component) {
const sources = [];
try {
if (component.iconUrl) sources.push(new URL(component.iconUrl).href);
} catch {}
if (knownComponentIcons[component.id]) sources.push(knownComponentIcons[component.id]);
try {
const origin = new URL(component.url).origin;
sources.push(`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`);
} catch {}
return [...new Set(sources)];
}
function componentIcon(component) {
const icon = element("span", "component-icon");
icon.append(element("span", "component-icon-fallback", component.icon || component.id.slice(0, 2).toUpperCase()));
const sources = componentIconSources(component);
const trySource = (index) => {
if (index >= sources.length) return;
const image = document.createElement("img");
image.alt = "";
image.decoding = "async";
image.referrerPolicy = "no-referrer";
let finished = false;
const timeout = window.setTimeout(() => {
finished = true;
trySource(index + 1);
}, 4000);
image.addEventListener("load", () => {
if (finished) return;
finished = true;
window.clearTimeout(timeout);
icon.querySelector("img")?.remove();
icon.append(image);
icon.classList.add("has-image");
}, {once: true});
image.addEventListener("error", () => {
if (finished) return;
finished = true;
window.clearTimeout(timeout);
trySource(index + 1);
}, {once: true});
image.src = sources[index];
};
trySource(0);
return icon;
}
function formatTime(value) {
if (!value) {
return "—";
}
return new Date(value).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
async function request(path, options = {}) {
const response = await fetch(path, {
cache: "no-store",
...options,
headers: {
"Accept": "application/json",
...(options.body ? {"Content-Type": "application/json"} : {}),
...(options.headers || {})
}
});
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch {
payload = {error: text};
}
if (!response.ok) {
throw new Error(payload.error || `HTTP ${response.status}`);
}
return payload;
}
function actionButton(label, handler, {dangerous = false, disabled = false} = {}) {
const button = element("button", `admin-action${dangerous ? " dangerous" : ""}`, label);
button.type = "button";
button.disabled = disabled;
button.addEventListener("click", handler);
return button;
}
function pill(label, tone = "") {
return element("span", `admin-pill${tone ? ` ${tone}` : ""}`, label);
}
async function loadSession() {
const payload = await request("/api/admin/session");
state.session = payload.identity;
document.getElementById("admin-session").textContent =
`${payload.identity.username} · ${payload.identity.provider} · ${payload.identity.sub}`;
}
function groupChecks(selectedIds) {
const wrapper = element("div", "admin-checks");
for (const group of state.identity?.groups || []) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.value = group.id;
input.checked = selectedIds.includes(group.id);
label.append(input, document.createTextNode(group.name));
wrapper.append(label);
}
return wrapper;
}
function renderIdentityUser(user) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const title = element("div");
title.append(element("h3", "", user.name || user.username), element("code", "", `${user.username} · ${user.uuid}`));
const badges = element("div", "admin-component-status");
badges.append(
pill(user.active ? "可登录" : "已停用", user.active ? "monitor-up" : "monitor-down"),
pill(`TOTP ${user.totpCount}`),
pill(`Passkey ${user.passkeyCount}`),
...(user.administrator ? [pill("管理员", "monitor-up")] : [])
);
heading.append(title, badges);
const meta = element("p", "admin-help", `${user.email || "未设置邮箱"} · 最近登录 ${formatTime(user.lastLogin)} · 创建 ${formatTime(user.createdAt)}`);
const groups = element("p", "admin-help", `用户组:${user.groups.map((group) => group.name).join("、") || "无"}`);
const actions = element("div", "admin-actions");
actions.append(actionButton("编辑", async () => {
const name = window.prompt("显示名", user.name || user.username);
if (name === null) return;
const email = window.prompt("邮箱,可留空", user.email || "");
if (email === null) return;
await mutateIdentity(`/api/admin/identity/users/${user.id}/update`, {name, email});
}));
if (user.username !== "liooil") {
actions.append(actionButton(user.active ? "停用" : "启用", () => mutateIdentity(
`/api/admin/identity/users/${user.id}/${user.active ? "disable" : "enable"}`,
{}
), {dangerous: user.active}));
}
actions.append(actionButton("设置临时密码", async () => {
const password = window.prompt("输入至少 12 个字符的临时密码");
if (password) await mutateIdentity(`/api/admin/identity/users/${user.id}/password`, {password});
}));
actions.append(actionButton("生成恢复链接", async () => {
const payload = await mutateIdentity(`/api/admin/identity/users/${user.id}/recovery`, {}, false);
window.prompt("一小时内有效的密码设置链接", payload.result.link);
}));
actions.append(actionButton("注销全部会话", () => mutateIdentity(`/api/admin/identity/users/${user.id}/sessions`, {}), {dangerous: true}));
if (user.totpCount) actions.append(actionButton("重置 TOTP", async () => {
if (window.confirm(`删除 ${user.username} 的全部 ${user.totpCount} 个 TOTP 设备?`)) await mutateIdentity(`/api/admin/identity/users/${user.id}/reset-totp`, {});
}, {dangerous: true}));
if (user.passkeyCount) actions.append(actionButton("重置 Passkey", async () => {
if (window.confirm(`删除 ${user.username} 的全部 ${user.passkeyCount} 个 Passkey`)) await mutateIdentity(`/api/admin/identity/users/${user.id}/reset-passkeys`, {});
}, {dangerous: true}));
record.append(heading, meta, groups, actions);
return record;
}
function renderIdentityGroup(group) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const title = element("div");
title.append(element("h3", "", group.name), element("code", "", group.id));
heading.append(title, pill(`${group.users.length} 个用户`));
const checks = element("div", "admin-checks");
for (const user of state.identity.users) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.value = String(user.id);
input.checked = group.userIds.includes(user.id);
label.append(input, document.createTextNode(user.username));
checks.append(label);
}
const actions = element("div", "admin-actions");
actions.append(actionButton("保存成员", async () => {
const desired = new Set([...checks.querySelectorAll("input:checked")].map((input) => Number(input.value)));
for (const user of state.identity.users) {
const current = group.userIds.includes(user.id);
if (current !== desired.has(user.id)) {
await request(`/api/admin/identity/groups/${group.id}/members`, {
method: "POST",
body: JSON.stringify({userId: user.id, member: desired.has(user.id)})
});
}
}
await loadIdentity();
}));
if (group.name !== "liuhome") {
actions.append(actionButton("重命名", async () => {
const name = window.prompt("用户组名称", group.name);
if (name) await mutateIdentity(`/api/admin/identity/groups/${group.id}/update`, {name});
}));
actions.append(actionButton("删除", async () => {
if (window.confirm(`删除用户组 ${group.name}`)) await mutateIdentity(`/api/admin/identity/groups/${group.id}/delete`, {});
}, {dangerous: true}));
}
record.append(heading, checks, actions);
return record;
}
function renderIdentityApplication(application) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const title = element("div");
title.append(element("h3", "", application.name), element("code", "", application.slug));
heading.append(title, pill(application.available ? (application.allowedGroups.join("、") || "所有已登录用户") : "未配置", application.available ? "monitor-up" : "monitor-down"));
const checks = groupChecks(application.allowedGroupIds);
const actions = element("div", "admin-actions");
actions.append(actionButton("保存允许组", async () => {
const groupIds = [...checks.querySelectorAll("input:checked")].map((input) => input.value);
await mutateIdentity(`/api/admin/identity/applications/${application.slug}/groups`, {groupIds});
}, {disabled: !application.available}));
record.append(heading, checks, actions);
return record;
}
function renderIdentity() {
const status = document.getElementById("identity-status");
status.className = `admin-notice ${state.identity.healthy ? "success" : "warning"}`;
status.textContent = state.identity.healthy
? `Authentik 已连接;基础访问组为 ${state.identity.protectedGroup}`
: "Authentik 健康检查失败。";
document.getElementById("admin-users").textContent = String(state.identity.users.length);
const users = document.getElementById("identity-users");
users.replaceChildren(...state.identity.users.map(renderIdentityUser));
const groups = document.getElementById("identity-groups");
groups.replaceChildren(...state.identity.groups.map(renderIdentityGroup));
const applications = document.getElementById("identity-applications");
applications.replaceChildren(...state.identity.applications.map(renderIdentityApplication));
const audit = document.getElementById("identity-audit");
audit.replaceChildren(...state.identity.audit.map((event) => {
const row = element("div", "audit-row");
row.append(element("time", "", formatTime(event.createdAt)), element("code", "", event.actor), element("strong", "", event.action), element("span", "", event.target));
return row;
}));
if (!state.identity.audit.length) audit.append(element("div", "empty-state", "暂无身份修改记录。"));
}
async function loadIdentity() {
const query = state.identitySearch ? `?search=${encodeURIComponent(state.identitySearch)}` : "";
state.identity = await request(`/api/admin/identity${query}`);
renderIdentity();
}
async function mutateIdentity(path, body, reload = true) {
const payload = await request(path, {method: "POST", body: JSON.stringify(body)});
if (reload) await loadIdentity();
return payload;
}
function credentialActions(credential) {
const actions = element("div", "admin-actions");
actions.append(actionButton("校验密文", () => runCredentialAction(credential, "verify")));
actions.append(actionButton("替换", async () => {
const apiKey = window.prompt(`输入 ${credential.providerId} / ${credential.name} 的新 API Key`);
if (!apiKey) return;
await runCredentialAction(credential, "replace", {secret: {provider: {apiKey}}});
}));
actions.append(actionButton("永久删除", async () => {
if (window.confirm(`永久删除 ${credential.providerId} / ${credential.name}`)) {
await runCredentialAction(credential, "delete");
}
}, {dangerous: true}));
return actions;
}
function renderCredential(credential) {
const record = element("article", "admin-record");
const heading = element("div", "admin-record-heading");
const identity = element("div");
identity.append(element("h3", "", `${credential.providerId} / ${credential.name}`));
identity.append(element("code", "", credential.id));
const badges = element("div", "admin-component-status");
badges.append(pill(`sha256:${credential.fingerprint}`));
heading.append(identity, badges);
const details = element("dl", "admin-details vault-details");
const values = [
["所有者", `${credential.owner.username} · ${credential.owner.sub}`],
["身份源", credential.owner.issuer],
["创建", formatTime(credential.createdAt)],
["更新", formatTime(credential.updatedAt)]
];
for (const [name, value] of values) {
const item = element("div");
item.append(element("dt", "", name), element("dd", "", value));
details.append(item);
}
record.append(heading, details, credentialActions(credential));
return record;
}
function renderAuditEvent(event) {
const row = element("div", "audit-row");
row.append(
element("time", "", formatTime(event.createdAt)),
element("code", "", event.actorUsername),
element("strong", "", event.action),
element("span", "", event.detail || event.targetId || "—"),
pill(event.result, event.result === "success" ? "monitor-up" : "monitor-down")
);
return row;
}
function renderVault() {
const credentials = document.getElementById("vault-credentials");
credentials.replaceChildren();
if (state.credentials.length) {
state.credentials.forEach((credential) => credentials.append(renderCredential(credential)));
} else {
credentials.append(element("div", "empty-state", "Vault 中没有 Backend Credential。"));
}
document.getElementById("admin-credentials").textContent = String(state.credentials.length);
const providerSelect = document.getElementById("admin-credential-provider");
providerSelect.replaceChildren();
state.providers.filter((provider) => provider.connection.type === "backend").forEach((provider) => {
providerSelect.append(new Option(`${provider.name} (${provider.id})`, provider.id));
});
const audit = document.getElementById("vault-audit");
audit.replaceChildren();
if (state.audit.length) state.audit.forEach((event) => audit.append(renderAuditEvent(event)));
else audit.append(element("div", "empty-state", "暂无 Vault 审计事件。"));
}
async function loadVault() {
try {
const [providers, credentials, audit] = await Promise.all([
request("/api/admin/vault/providers"),
request("/api/admin/vault/credentials"),
request("/api/admin/vault/audit")
]);
state.providers = providers.providers || [];
state.credentials = credentials.credentials || [];
state.audit = audit.events || [];
renderVault();
} catch (error) {
document.getElementById("vault-credentials").replaceChildren(element("div", "error-state", `Key Vault 暂时不可用:${error.message}`));
}
}
async function runCredentialAction(credential, action, body = {}) {
await withBusy(`credential-${credential.id}`, async () => {
const payload = await request(`/api/admin/vault/credentials/${credential.id}/${action}`, {
method: "POST",
body: JSON.stringify(body)
});
if (action === "verify") {
window.alert(payload.verified ? "密文完整,可以使用。" : "密文校验失败,请立即替换或吊销。" );
}
await loadVault();
});
}
function componentActionButton(component, action, label, dangerous = false) {
return actionButton(label, () => runComponentAction(component, action), {
dangerous,
disabled: state.busy.has(`component-${component.id}`)
});
}
function componentActions(component) {
const actions = element("div", "admin-actions");
actions.append(component.lifecycle === "archived"
? componentActionButton(component, "restore", "恢复")
: componentActionButton(component, "archive", "归档"));
if (component.monitor.enabled) {
actions.append(component.monitor.status === "paused"
? componentActionButton(component, "resume", "恢复探测")
: componentActionButton(component, "pause", "暂停探测"));
}
if (["missing", "archived"].includes(component.lifecycle)) {
actions.append(componentActionButton(component, "purge", "永久清理", true));
}
return actions;
}
function componentRow(component) {
const row = element("article", "admin-component");
const identity = element("div", "admin-component-identity");
identity.append(componentIcon(component));
const title = element("div");
title.append(element("h2", "", component.name), element("code", "", component.id));
identity.append(title);
const status = element("div", "admin-component-status");
status.append(
pill(lifecycleLabels[component.lifecycle] || component.lifecycle, `lifecycle-${component.lifecycle}`),
pill(monitorLabels[component.monitor.status] || component.monitor.status, `monitor-${component.monitor.status}`)
);
const details = element("dl", "admin-details");
const values = [
["最后发现", formatTime(component.lastSeen)],
["最后检查", formatTime(component.monitor.checkedAt)],
["响应时间", Number.isFinite(component.monitor.responseTimeMs) ? `${component.monitor.responseTimeMs} ms` : "—"],
["24h 可用率", Number.isFinite(component.monitor.uptime24h) ? `${component.monitor.uptime24h.toFixed(2)}%` : "—"]
];
for (const [name, value] of values) {
const item = element("div");
item.append(element("dt", "", name), element("dd", "", value));
details.append(item);
}
if (component.monitor.error) {
details.append(element("p", "admin-error", `最近错误:${component.monitor.error}`));
}
row.append(identity, status, details, componentActions(component));
return row;
}
function renderComponents() {
const target = document.getElementById("admin-components");
target.replaceChildren();
document.getElementById("admin-total").textContent = String(state.components.length);
document.getElementById("admin-down").textContent = String(
state.components.filter((component) => component.monitor.status === "down").length
);
if (!state.components.length) {
target.append(element("div", "empty-state", "Registry 中没有组件。"));
return;
}
state.components.forEach((component) => target.append(componentRow(component)));
}
async function loadComponents() {
try {
const payload = await request("/api/admin/components");
state.components = Array.isArray(payload.components) ? payload.components : [];
renderComponents();
document.getElementById("admin-updated").textContent = `更新于 ${formatTime(payload.generatedAt)}`;
} catch (error) {
document.getElementById("admin-components").replaceChildren(
element("div", "error-state", `管理数据暂时不可用:${error.message}`)
);
document.getElementById("admin-updated").textContent = "Registry 管理接口不可用";
}
}
async function runComponentAction(component, action) {
if (action === "purge" && !window.confirm(`永久清理 ${component.name} 及其全部监控历史?此操作不可撤销。`)) {
return;
}
await withBusy(`component-${component.id}`, async () => {
await request(`/api/admin/components/${component.id}/${action}`, {method: "POST", body: "{}"});
await loadComponents();
});
}
async function withBusy(key, operation) {
if (state.busy.has(key)) {
return;
}
state.busy.add(key);
try {
await operation();
} catch (error) {
window.alert(`操作失败:${error.message}`);
} finally {
state.busy.delete(key);
}
}
document.getElementById("vault-refresh").addEventListener("click", loadVault);
document.getElementById("admin-refresh").addEventListener("click", loadComponents);
document.getElementById("identity-refresh").addEventListener("click", loadIdentity);
document.getElementById("identity-search-form").addEventListener("submit", async (event) => {
event.preventDefault();
state.identitySearch = String(new FormData(event.currentTarget).get("search") || "").trim();
await loadIdentity();
});
document.getElementById("user-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
await withBusy("user-create", async () => {
await mutateIdentity("/api/admin/identity/users", Object.fromEntries(new FormData(event.currentTarget).entries()));
event.currentTarget.reset();
});
});
document.getElementById("group-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
await withBusy("group-create", async () => {
await mutateIdentity("/api/admin/identity/groups", Object.fromEntries(new FormData(event.currentTarget).entries()));
event.currentTarget.reset();
});
});
document.getElementById("credential-create-form").addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const payload = {
providerId: data.get("providerId"),
name: data.get("name"),
secret: {provider: {apiKey: data.get("apiKey")}}
};
await withBusy("credential-create", async () => {
await request("/api/admin/vault/credentials", {
method: "POST",
body: JSON.stringify(payload)
});
event.currentTarget.reset();
await loadVault();
});
});
async function initialize() {
try {
await loadSession();
await Promise.all([loadIdentity(), loadVault(), loadComponents()]);
} catch (error) {
document.getElementById("admin-session").textContent = `管理员会话不可用:${error.message}`;
}
}
initialize();
+456
View File
@@ -0,0 +1,456 @@
const state = {
components: [],
filter: "all",
generatedAt: null
};
const publicAccessModes = new Set(["public"]);
const onlineStates = new Set(["up"]);
const knownComponentIcons = {
gitea: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg",
hedgedoc: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg",
hedgedoc2: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg",
"code-server": "https://cdn.simpleicons.org/coder",
chat: "https://xiteng.site/icons/services/chat.svg",
comfyui: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg",
invokeai: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg",
authentik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"authentik-worker": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"authentik-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"seaweedfs-outpost": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg",
"authentik-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
"gitea-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
"hedgedoc-db": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg",
"authentik-redis": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg",
"cloudflare-tunnel": "https://cdn.simpleicons.org/cloudflare",
traefik: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/traefik.svg",
portal: "https://xiteng.site/favicon.svg"
};
const requestedFocusId = new URLSearchParams(window.location.search).get("focus") || "";
const focusComponentId = /^[a-z0-9][a-z0-9-]*$/.test(requestedFocusId) ? requestedFocusId : "";
let focusHandled = false;
const statusLabels = {
running: "运行中",
restarting: "重启中",
unhealthy: "异常",
degraded: "部分异常",
paused: "已暂停",
exited: "已停止",
dead: "不可用",
created: "待启动",
missing: "已消失",
archived: "已归档",
unknown: "未知"
};
const monitorLabels = {
up: "服务可用",
down: "服务不可用",
degraded: "等待重试",
pending: "等待检查",
paused: "探测暂停",
unmonitored: "未配置探测"
};
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) {
return "—";
}
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const digits = value >= 100 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unitIndex]}`;
}
function normalizedPercent(value) {
return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
}
function updateMetric(name, percent, detail, secondary) {
const value = normalizedPercent(percent);
document.getElementById(`${name}-value`).textContent = Number.isFinite(percent) ? `${value.toFixed(1)}%` : "N/A";
const progress = document.getElementById(`${name}-progress`);
progress.value = value;
progress.textContent = `${value.toFixed(1)}%`;
document.getElementById(`${name}-detail`).textContent = detail;
document.getElementById(`${name}-secondary`).textContent = secondary;
}
function renderMetrics(payload) {
document.getElementById("device-hostname").textContent = payload.hostname || "HOMELAB";
updateMetric(
"cpu",
payload.cpu?.usagePercent,
`${payload.cpu?.logicalCores || "—"} 线程 · ${payload.cpu?.model || "Unknown CPU"}`,
`LOAD ${payload.cpu?.load1?.toFixed(2) ?? "—"} / ${payload.cpu?.load5?.toFixed(2) ?? "—"} / ${payload.cpu?.load15?.toFixed(2) ?? "—"}`
);
updateMetric(
"memory",
payload.memory?.usagePercent,
`${formatBytes(payload.memory?.usedBytes)} / ${formatBytes(payload.memory?.totalBytes)}`,
`AVAILABLE ${formatBytes(payload.memory?.availableBytes)}`
);
updateMetric(
"disk",
payload.disk?.usagePercent,
`${formatBytes(payload.disk?.usedBytes)} / ${formatBytes(payload.disk?.totalBytes)}`,
`${payload.disk?.device || "ROOT"} · AVAILABLE ${formatBytes(payload.disk?.availableBytes)}`
);
const gpu = payload.gpu?.devices?.[0];
if (payload.gpu?.available && gpu) {
updateMetric(
"gpu",
gpu.utilizationPercent,
gpu.name,
`VRAM ${gpu.memoryUsedMiB?.toFixed(0) ?? "—"} / ${gpu.memoryTotalMiB?.toFixed(0) ?? "—"} MiB · ${gpu.temperatureCelsius?.toFixed(0) ?? "—"}°C · ${gpu.powerDrawWatts?.toFixed(0) ?? "—"} W`
);
} else {
updateMetric("gpu", null, "未发现 NVIDIA GPU", "VRAM —");
}
const updated = new Date(payload.generatedAt);
document.getElementById("metrics-updated").textContent = `实时公开指标 · ${updated.toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit"})}`;
}
function renderMetricsError() {
for (const name of ["cpu", "memory", "disk", "gpu"]) {
updateMetric(name, null, "指标暂时不可用", "等待采集器恢复");
}
document.getElementById("metrics-updated").textContent = "主机指标暂时不可用";
}
async function loadMetrics() {
try {
const response = await fetch("/api/metrics", {
headers: {"Accept": "application/json"},
cache: "no-store"
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
renderMetrics(await response.json());
} catch (error) {
console.error("Failed to load host metrics", error);
renderMetricsError();
}
}
function createElement(tagName, className, text) {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
if (text !== undefined) {
element.textContent = text;
}
return element;
}
function componentIconSources(component) {
const sources = [];
const explicit = safeHref(component.iconUrl);
if (explicit) sources.push(explicit);
const known = safeHref(knownComponentIcons[component.id]);
if (known) sources.push(known);
const service = safeHref(component.url);
if (service) {
const origin = new URL(service).origin;
sources.push(`${origin}/favicon.svg`, `${origin}/favicon.ico`, `${origin}/favicon.png`);
}
return [...new Set(sources)];
}
function componentIcon(component) {
const icon = createElement("span", "component-icon");
icon.append(createElement("span", "component-icon-fallback", component.icon || component.name.slice(0, 2).toUpperCase()));
const sources = componentIconSources(component);
const trySource = (index) => {
if (index >= sources.length) return;
const image = document.createElement("img");
image.alt = "";
image.decoding = "async";
image.referrerPolicy = "no-referrer";
let finished = false;
const timeout = window.setTimeout(() => {
finished = true;
trySource(index + 1);
}, 4000);
image.addEventListener("load", () => {
if (finished) return;
finished = true;
window.clearTimeout(timeout);
icon.querySelector("img")?.remove();
icon.append(image);
icon.classList.add("has-image");
}, {once: true});
image.addEventListener("error", () => {
if (finished) return;
finished = true;
window.clearTimeout(timeout);
trySource(index + 1);
}, {once: true});
image.src = sources[index];
};
trySource(0);
return icon;
}
function isPublic(component) {
return publicAccessModes.has(component.access);
}
function matchesFilter(component) {
if (state.filter === "public") {
return isPublic(component);
}
if (state.filter === "restricted") {
return !isPublic(component);
}
if (state.filter === "online") {
return onlineStates.has(component.monitor?.status);
}
return true;
}
function safeHref(value) {
if (!value) {
return null;
}
try {
const url = new URL(value);
return ["http:", "https:"].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
function componentCard(component) {
const href = safeHref(component.url);
const card = createElement(href ? "a" : "article", `component-card accent-${component.accent || "ink"}`);
card.dataset.componentId = component.id;
if (href) {
card.href = href;
if (component.navigation !== "same-tab") {
card.target = "_blank";
card.rel = "noopener noreferrer";
}
card.setAttribute("aria-label", component.navigation === "same-tab" ? `打开 ${component.name}` : `在新标签打开 ${component.name}`);
}
const head = createElement("div", "component-head");
head.append(componentIcon(component));
const states = createElement("div", "component-state-stack");
const status = createElement(
"span",
`component-status status-${component.status || "unknown"}`,
`容器 · ${statusLabels[component.status] || statusLabels.unknown}`
);
states.append(status);
if (component.monitor?.enabled) {
states.append(createElement(
"span",
`component-status component-monitor-status monitor-${component.monitor.status || "pending"}`,
monitorLabels[component.monitor.status] || "等待检查"
));
}
head.append(states);
card.append(head);
card.append(createElement("p", "component-kicker", component.category || "组件"));
card.append(createElement("h3", "", component.name));
card.append(createElement("p", "component-description", component.description || "未提供说明"));
const foot = createElement("div", "component-foot");
const tags = createElement("div", "component-tags");
tags.append(createElement(
"span",
`tag ${isPublic(component) ? "access-public" : "access-restricted"}`,
component.accessLabel || (isPublic(component) ? "无需登录" : "受控访问")
));
if (component.endpoint) {
tags.append(createElement("span", "tag", component.endpoint));
}
if (component.instanceCount > 1) {
tags.append(createElement("span", "tag", `${component.instanceCount} 实例`));
}
if (component.monitor?.enabled && Number.isFinite(component.monitor.uptime24h)) {
tags.append(createElement("span", "tag", `24H ${component.monitor.uptime24h.toFixed(2)}%`));
}
if (component.monitor?.enabled && Number.isFinite(component.monitor.responseTimeMs)) {
tags.append(createElement("span", "tag", `${component.monitor.responseTimeMs} ms`));
}
foot.append(tags);
const meta = createElement("div", "component-meta");
meta.append(createElement("span", "", component.image || component.service || "Docker service"));
if (href) {
meta.append(createElement("span", "component-open", component.navigation === "same-tab" ? "打开 →" : "新标签打开 ↗"));
} else {
meta.append(createElement("span", "", component.service || "internal"));
}
foot.append(meta);
card.append(foot);
return card;
}
function renderCatalog(targetId, section) {
const target = document.getElementById(targetId);
const visible = state.components.filter((component) => component.section === section && matchesFilter(component));
target.replaceChildren();
if (!visible.length) {
target.append(createElement("div", "empty-state", "当前筛选条件下没有组件。"));
return;
}
const groups = new Map();
for (const component of visible) {
const category = component.category || "其他";
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category).push(component);
}
for (const [category, components] of groups) {
const group = createElement("section", "category-group");
group.append(createElement("h3", "category-title", `${category} · ${components.length}`));
const grid = createElement("div", "component-grid");
for (const component of components) {
grid.append(componentCard(component));
}
group.append(grid);
target.append(group);
}
}
function renderSummary() {
const total = state.components.length;
const running = state.components.filter((component) => component.monitor?.status === "up").length;
const restricted = state.components.filter((component) => !isPublic(component)).length;
document.getElementById("component-count").textContent = String(total);
document.getElementById("running-count").textContent = String(running);
document.getElementById("restricted-count").textContent = String(restricted);
const updated = document.getElementById("catalog-updated");
if (state.generatedAt) {
const date = new Date(state.generatedAt);
updated.textContent = `统一发现与健康检查 · 更新于 ${date.toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit"})}`;
}
}
function focusReturnedComponent() {
if (focusHandled || !focusComponentId) return;
const card = document.querySelector(`[data-component-id="${CSS.escape(focusComponentId)}"]`);
if (!card) return;
focusHandled = true;
window.requestAnimationFrame(() => {
card.scrollIntoView({behavior: "smooth", block: "center"});
card.classList.add("return-focus");
window.setTimeout(() => card.classList.remove("return-focus"), 1800);
});
const url = new URL(window.location.href);
url.searchParams.delete("focus");
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
}
function render() {
renderSummary();
renderCatalog("services-catalog", "services");
renderCatalog("infrastructure-catalog", "infrastructure");
focusReturnedComponent();
}
function renderError(message) {
for (const targetId of ["services-catalog", "infrastructure-catalog"]) {
const target = document.getElementById(targetId);
target.replaceChildren(createElement("div", "error-state", message));
}
document.getElementById("catalog-updated").textContent = "组件目录暂时不可用";
}
async function loadComponents() {
try {
const response = await fetch("/api/components", {
headers: {"Accept": "application/json"},
cache: "no-store"
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!Array.isArray(payload.components)) {
throw new Error("invalid registry response");
}
state.components = payload.components;
if (focusComponentId && state.components.some((component) => component.id === focusComponentId)) {
state.filter = "all";
for (const button of document.querySelectorAll(".filter")) {
button.classList.toggle("active", button.dataset.filter === "all");
}
}
state.generatedAt = payload.generatedAt;
render();
} catch (error) {
console.error("Failed to load component catalog", error);
renderError("无法读取动态组件目录,请稍后刷新。");
}
}
for (const button of document.querySelectorAll(".filter")) {
button.addEventListener("click", () => {
state.filter = button.dataset.filter || "all";
for (const item of document.querySelectorAll(".filter")) {
item.classList.toggle("active", item === button);
}
render();
});
}
document.getElementById("year").textContent = String(new Date().getFullYear());
loadComponents();
loadMetrics();
setInterval(() => {
if (document.visibilityState === "visible") {
loadComponents();
}
}, 30000);
setInterval(() => {
if (document.visibilityState === "visible") {
loadMetrics();
}
}, 5000);
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((error) => {
console.error("Failed to register service worker", error);
});
});
}
+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};
}
+144
View File
@@ -0,0 +1,144 @@
import test from "node:test";
import assert from "node:assert/strict";
import {mkdtempSync, readFileSync, rmSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {createAuthentikAdmin} from "./authentik.mjs";
const user = {
pk: 3,
uuid: "user-uuid",
username: "liooil",
name: "西腾",
email: "[email protected]",
is_active: true,
is_superuser: true,
type: "internal",
path: "users",
groups_obj: [{pk: "group-id", name: "liuhome"}],
last_login: null,
date_joined: "2026-01-01T00:00:00Z"
};
const group = {pk: "group-id", name: "liuhome", is_superuser: false, users: [3], users_obj: [{pk: 3, username: "liooil", name: "西腾"}]};
const application = {pk: "app-id", slug: "xiteng-chat", name: "Xiteng Chat"};
const binding = {pk: "binding-id", target: "app-id", group: "group-id", enabled: true};
function response(payload, status = 200) {
return new Response(payload === null ? null : JSON.stringify(payload), {status, headers: {"Content-Type": "application/json"}});
}
test("summarizes only human users and liuhome application access", async () => {
const directory = mkdtempSync(join(tmpdir(), "authentik-admin-test-"));
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const path = new URL(url).pathname;
if (path === "/-/health/ready/") return response({status: "ok"});
if (path === "/api/v3/core/users/") return response({results: [user, {...user, pk: 9, username: "ak-outpost", path: "goauthentik.io/outposts"}]});
if (path === "/api/v3/core/groups/") return response({results: [group]});
if (path === "/api/v3/core/applications/") return response({results: [application]});
if (path === "/api/v3/policies/bindings/") return response({results: [binding]});
if (path === "/api/v3/authenticators/admin/totp/") return response({results: [{pk: 1, name: "Phone", user: {pk: 3}}]});
if (path === "/api/v3/authenticators/admin/webauthn/") return response({results: [{pk: 2, name: "Laptop", user: {pk: 3}}]});
throw new Error(`Unexpected URL ${url}`);
};
try {
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: join(directory, "audit.jsonl")});
const summary = await admin.summary();
assert.deepEqual(summary.users.map((item) => item.username), ["liooil"]);
assert.deepEqual(summary.groups.map((item) => item.name), ["liuhome"]);
assert.deepEqual(summary.applications.find((item) => item.slug === "xiteng-chat").allowedGroups, ["liuhome"]);
assert.equal(summary.users[0].totpCount, 1);
assert.equal(summary.users[0].passkeyCount, 1);
} finally {
globalThis.fetch = originalFetch;
rmSync(directory, {recursive: true, force: true});
}
});
test("refuses to disable liooil", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
if (new URL(url).pathname === "/api/v3/core/users/3/") return response(user);
throw new Error(`Unexpected URL ${url}`);
};
try {
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"});
await assert.rejects(() => admin.mutate("/api/admin/identity/users/3/disable", {}, "liooil"), (error) => {
assert.equal(error.statusCode, 409);
return /不能停用/.test(error.message);
});
} finally {
globalThis.fetch = originalFetch;
}
});
test("does not write temporary passwords to the identity audit", async () => {
const directory = mkdtempSync(join(tmpdir(), "authentik-audit-test-"));
const auditPath = join(directory, "audit.jsonl");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options = {}) => {
const path = new URL(url).pathname;
if (path === "/api/v3/core/users/3/") return response(user);
if (path === "/api/v3/core/users/3/set_password/" && options.method === "POST") return response(null, 204);
throw new Error(`Unexpected URL ${url}`);
};
try {
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath});
await admin.mutate("/api/admin/identity/users/3/password", {password: "Temporary-secret-2026"}, "liooil");
const audit = readFileSync(auditPath, "utf8");
assert.doesNotMatch(audit, /Temporary-secret-2026/);
assert.match(audit, /user\.password/);
} finally {
globalThis.fetch = originalFetch;
rmSync(directory, {recursive: true, force: true});
}
});
test("returns only the current user's authenticators and sessions", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const parsed = new URL(url);
const path = parsed.pathname;
if (path === "/api/v3/core/users/") return response({results: [user]});
if (path === "/api/v3/authenticators/admin/totp/") return response({results: [
{pk: 1, name: "Phone", user: {pk: 3}},
{pk: 9, name: "Other", user: {pk: 99}}
]});
if (path === "/api/v3/authenticators/admin/webauthn/") return response({results: [
{pk: 2, name: "Laptop", created_on: "2026-01-02T00:00:00Z", device_type: "single_device", aaguid: "aaguid", user: {pk: 3}}
]});
if (path === "/api/v3/core/authenticated_sessions/") return response({results: [
{uuid: "session-id", user: 3, current: true, last_ip: "127.0.0.1", last_user_agent: "Browser", last_used: "2026-01-03T00:00:00Z", expires: "2026-01-04T00:00:00Z"},
{uuid: "other-session", user: 99}
]});
throw new Error(`Unexpected URL ${url}`);
};
try {
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"});
const account = await admin.accountSummary("liooil");
assert.equal(account.profile.username, "liooil");
assert.deepEqual(account.security.totp.map((device) => device.name), ["Phone"]);
assert.deepEqual(account.security.passkeys.map((device) => device.name), ["Laptop"]);
assert.deepEqual(account.security.sessions.map((session) => session.id), ["session-id"]);
assert.match(account.security.totpSetupUrl, /default-authenticator-totp-setup/);
assert.match(account.security.passkeySetupUrl, /default-authenticator-webauthn-setup/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("refuses to mutate another user's authenticator", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const path = new URL(url).pathname;
if (path === "/api/v3/core/users/") return response({results: [user]});
if (path === "/api/v3/authenticators/admin/webauthn/9/") return response({pk: 9, name: "Other", user: {pk: 99}});
throw new Error(`Unexpected URL ${url}`);
};
try {
const admin = createAuthentikAdmin({baseUrl: "https://auth.example/", token: "token", auditPath: "/tmp/unused-authentik-audit"});
await assert.rejects(() => admin.accountMutate("/api/account/security/passkeys/9/delete", {}, "liooil"), (error) => error.statusCode === 404);
} finally {
globalThis.fetch = originalFetch;
}
});
+211
View File
@@ -0,0 +1,211 @@
services:
metrics:
image: python:3-slim@sha256:a7fb1e634c4a578f9e0bd6327f11a3cde11b7a9395f48e24360c0988bcc5c2bc
container_name: xiteng-site-metrics
restart: unless-stopped
command: ["python3", "/app/metrics.py"]
environment:
- PORT=8092
- HOST_PROC=/host/proc
- DISK_PATH=/host/disk
- CACHE_TTL_SECONDS=2
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=utility
volumes:
- ./metrics.py:/app/metrics.py:ro
- /proc:/host/proc:ro
- /etc/hostname:/host/hostname:ro
- ./:/host/disk:ro
read_only: true
security_opt:
- no-new-privileges:true
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8092/healthz', timeout=2)"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
networks:
- homelab_net
labels:
- "traefik.enable=false"
- "xiteng.site.component.host-metrics.enabled=true"
- "xiteng.site.component.host-metrics.name=Host Metrics"
- "xiteng.site.component.host-metrics.description=采集并公开 CPU、内存、磁盘与 GPU 的实时汇总指标。"
- "xiteng.site.component.host-metrics.section=infrastructure"
- "xiteng.site.component.host-metrics.category=可观测性"
- "xiteng.site.component.host-metrics.access=public"
- "xiteng.site.component.host-metrics.access-label=公开只读指标"
- "xiteng.site.component.host-metrics.icon=HW"
- "xiteng.site.component.host-metrics.accent=green"
- "xiteng.site.component.host-metrics.order=590"
registry:
image: node:24-alpine
container_name: xiteng-site-registry
restart: unless-stopped
command: ["node", "/app/registry.mjs"]
environment:
- PORT=8091
- DOCKER_SOCKET=/var/run/docker.sock
- DATABASE_PATH=/data/registry.db
- DISCOVERY_INTERVAL_MS=5000
- MISSING_RETENTION_DAYS=30
- RAW_RETENTION_DAYS=30
- AGGREGATE_RETENTION_DAYS=365
- MONITOR_CONCURRENCY=4
volumes:
- ./registry.mjs:/app/registry.mjs:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./data:/data
read_only: true
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8091/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
networks:
- homelab_net
labels:
- "traefik.enable=false"
- "xiteng.site.component.site-registry.enabled=true"
- "xiteng.site.component.site-registry.name=Component Registry"
- "xiteng.site.component.site-registry.description=只读发现 Docker Label,并向门户输出白名单组件元数据。"
- "xiteng.site.component.site-registry.section=infrastructure"
- "xiteng.site.component.site-registry.category=门户"
- "xiteng.site.component.site-registry.access=internal"
- "xiteng.site.component.site-registry.access-label=仅容器网络"
- "xiteng.site.component.site-registry.icon=API"
- "xiteng.site.component.site-registry.accent=blue"
- "xiteng.site.component.site-registry.order=12"
xiteng-site:
image: node:24-alpine
container_name: xiteng-site
restart: unless-stopped
command: ["node", "/app/server.mjs"]
environment:
- PORT=8080
- REGISTRY_URL=http://xiteng-site-registry:8091/components
- REGISTRY_ADMIN_URL=http://xiteng-site-registry:8091/admin
- METRICS_URL=http://xiteng-site-metrics:8092/metrics
- KEY_VAULT_URL=http://ai-gateway:8093
- PROVIDER_TEST_URL=http://xiteng-chat:3000/api/provider-test
- KEY_VAULT_TOKEN_FILE=/run/secrets/portal_gateway_hmac
- ADMIN_USERNAME=liooil
- AUTHENTIK_ISSUER=https://auth.xiteng.site
- AUTHENTIK_URL=https://auth.xiteng.site
- AUTHENTIK_TOKEN_FILE=/run/authentik-secrets/portal_api_token
- IDENTITY_AUDIT_PATH=/data/identity-audit.jsonl
volumes:
- ./index.html:/app/index.html:ro
- ./styles.css:/app/styles.css:ro
- ./app.js:/app/app.js:ro
- ./sw.js:/app/sw.js:ro
- ./manifest.webmanifest:/app/manifest.webmanifest:ro
- ./favicon.svg:/app/favicon.svg:ro
- ./favicon.ico:/app/favicon.ico:ro
- ./icons:/app/icons:ro
- ./admin.html:/app/admin.html:ro
- ./admin.js:/app/admin.js:ro
- ./account.html:/app/account.html:ro
- ./account.js:/app/account.js:ro
- ./authentik.mjs:/app/authentik.mjs:ro
- ./server.mjs:/app/server.mjs:ro
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro
- ../authentik/secrets/portal_api_token:/run/authentik-secrets/portal_api_token:ro
- ./data:/data
read_only: true
security_opt:
- no-new-privileges:true
depends_on:
registry:
condition: service_healthy
metrics:
condition: service_healthy
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.xiteng-site.rule=Host(`xiteng.site`)"
- "traefik.http.routers.xiteng-site.entrypoints=websecure"
- "traefik.http.routers.xiteng-site.tls=true"
- "traefik.http.routers.xiteng-site.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-site.service=xiteng-site"
- "traefik.http.routers.xiteng-site.middlewares=xiteng-site-nocache"
- "traefik.http.services.xiteng-site.loadbalancer.server.port=8080"
- "traefik.http.routers.xiteng-site-http.rule=Host(`xiteng.site`)"
- "traefik.http.routers.xiteng-site-http.service=xiteng-site"
- "traefik.http.routers.xiteng-site-http.entrypoints=web"
- "traefik.http.routers.xiteng-site-http.middlewares=xiteng-site-nocache"
- "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Cache-Control=no-store, no-cache, must-revalidate"
- "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Pragma=no-cache"
- "traefik.http.middlewares.xiteng-site-nocache.headers.customresponseheaders.Expires=0"
# Authentik ForwardAuth protects all Portal administration and account routes.
- "traefik.http.middlewares.xiteng-admin-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.xiteng-admin-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.xiteng-admin-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.xiteng-admin-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid"
- "traefik.http.middlewares.xiteng-admin-context.headers.customrequestheaders.X-Portal-Authenticated=1"
- "traefik.http.routers.xiteng-site-admin.rule=Host(`xiteng.site`) && (PathPrefix(`/admin`) || PathPrefix(`/account`) || PathPrefix(`/api/admin`) || PathPrefix(`/api/account`))"
- "traefik.http.routers.xiteng-site-admin.entrypoints=websecure"
- "traefik.http.routers.xiteng-site-admin.tls=true"
- "traefik.http.routers.xiteng-site-admin.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-site-admin.service=xiteng-site"
- "traefik.http.routers.xiteng-site-admin.middlewares=xiteng-admin-scheme,xiteng-admin-auth,xiteng-admin-context,xiteng-site-nocache"
- "traefik.http.routers.xiteng-site-admin.priority=200"
- "traefik.http.routers.xiteng-site-admin-http.rule=Host(`xiteng.site`) && (PathPrefix(`/admin`) || PathPrefix(`/account`) || PathPrefix(`/api/admin`) || PathPrefix(`/api/account`))"
- "traefik.http.routers.xiteng-site-admin-http.entrypoints=web"
- "traefik.http.routers.xiteng-site-admin-http.service=xiteng-site"
- "traefik.http.routers.xiteng-site-admin-http.middlewares=xiteng-admin-scheme,xiteng-admin-auth,xiteng-admin-context,xiteng-site-nocache"
- "traefik.http.routers.xiteng-site-admin-http.priority=200"
# The native Authentik admin UI is hidden; login, OAuth/OIDC and API endpoints remain available.
- "traefik.http.routers.authentik-admin-hidden.rule=Host(`auth.xiteng.site`) && PathPrefix(`/if/admin`)"
- "traefik.http.routers.authentik-admin-hidden.entrypoints=websecure"
- "traefik.http.routers.authentik-admin-hidden.tls=true"
- "traefik.http.routers.authentik-admin-hidden.tls.certresolver=cfresolver"
- "traefik.http.routers.authentik-admin-hidden.service=xiteng-site"
- "traefik.http.routers.authentik-admin-hidden.priority=5000"
- "traefik.http.routers.authentik-admin-hidden-http.rule=Host(`auth.xiteng.site`) && PathPrefix(`/if/admin`)"
- "traefik.http.routers.authentik-admin-hidden-http.entrypoints=web"
- "traefik.http.routers.authentik-admin-hidden-http.service=xiteng-site"
- "traefik.http.routers.authentik-admin-hidden-http.priority=5000"
- "xiteng.site.cache.portal-static.enabled=true"
- "xiteng.site.cache.portal-static.routers=xiteng-site,xiteng-site-http"
- "xiteng.site.cache.portal-static.paths=/icons/"
- "xiteng.site.cache.portal-static.edge-ttl=604800"
- "xiteng.site.cache.portal-static.stale-while-revalidate=86400"
# Dynamic catalog
- "xiteng.site.component.portal.enabled=true"
- "xiteng.site.component.portal.name=Xiteng Portal"
- "xiteng.site.component.portal.description=唯一公开入口、个人主页与 Authentik 保护的 Homelab 管理控制面。"
- "xiteng.site.component.portal.section=infrastructure"
- "xiteng.site.component.portal.category=门户"
- "xiteng.site.component.portal.url=https://xiteng.site"
- "xiteng.site.component.portal.access=public"
- "xiteng.site.component.portal.access-label=公开目录"
- "xiteng.site.component.portal.icon=XT"
- "xiteng.site.component.portal.icon-url=https://xiteng.site/favicon.svg"
- "xiteng.site.component.portal.accent=red"
- "xiteng.site.component.portal.order=10"
- "xiteng.site.component.portal.monitor.enabled=true"
- "xiteng.site.component.portal.monitor.url=http://xiteng-site:8080/healthz"
- "xiteng.site.component.portal.monitor.interval=60"
- "xiteng.site.component.portal.monitor.failures=3"
- "xiteng.site.component.portal.monitor.timeout=10"
- "xiteng.site.component.portal.monitor.accept=200-299"
networks:
homelab_net:
external: true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
<title id="title">Xiteng Portal</title>
<desc id="description">A red geometric X on a cream tile with a yellow background.</desc>
<rect width="512" height="512" rx="104" fill="#f0c94b"/>
<rect x="48" y="48" width="416" height="416" rx="80" fill="#fffaf0" stroke="#1c1712" stroke-width="16"/>
<path d="M150 146h70l36 65 36-65h70l-66 110 66 110h-70l-36-65-36 65h-70l66-110z" fill="#d83b21" stroke="#1c1712" stroke-width="14" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#171717"/><path d="M17 20h30v20H31l-9 8v-8h-5z" fill="#fff"/><circle cx="25" cy="30" r="2" fill="#171717"/><circle cx="32" cy="30" r="2" fill="#171717"/><circle cx="39" cy="30" r="2" fill="#171717"/></svg>

After

Width:  |  Height:  |  Size: 309 B

+142
View File
@@ -0,0 +1,142 @@
import {DatabaseSync} from "node:sqlite";
const sourcePath = process.env.KUMA_DATABASE || "/migration/kuma.db";
const targetPath = process.env.REGISTRY_DATABASE || "/data/registry.db";
const rawRetentionDays = Number.parseInt(process.env.RAW_RETENTION_DAYS || "30", 10);
const aggregateRetentionDays = Number.parseInt(process.env.AGGREGATE_RETENTION_DAYS || "365", 10);
const source = new DatabaseSync(sourcePath, {readOnly: true});
const target = new DatabaseSync(targetPath);
target.exec("PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
const components = target.prepare(`
SELECT id, monitor_json FROM registry_component WHERE monitor_json IS NOT NULL
`).all();
const sourceMonitors = source.prepare(`
SELECT m.id, m.name, m.url, COUNT(h.id) AS checks
FROM monitor m
LEFT JOIN heartbeat h ON h.monitor_id = m.id
GROUP BY m.id
ORDER BY checks DESC, m.id ASC
`).all();
const heartbeats = source.prepare(`
SELECT status, time, ping, msg
FROM heartbeat
WHERE monitor_id = ? AND time >= ?
ORDER BY time ASC
`);
const dailyStats = source.prepare(`
SELECT timestamp, ping, up, down
FROM stat_daily
WHERE monitor_id = ? AND timestamp >= ?
ORDER BY timestamp ASC
`);
const insertCheck = target.prepare(`
INSERT OR IGNORE INTO monitor_check (
component_id, checked_at, ok, status_code, latency_ms, error
) VALUES (?, ?, ?, ?, ?, ?)
`);
const upsertHourly = target.prepare(`
INSERT INTO monitor_hourly (component_id, bucket, total, successful, latency_sum)
VALUES (?, ?, 1, ?, ?)
ON CONFLICT(component_id, bucket) DO UPDATE SET
total = total + 1,
successful = successful + excluded.successful,
latency_sum = latency_sum + excluded.latency_sum
`);
const upsertDaily = target.prepare(`
INSERT INTO monitor_daily (component_id, bucket, total, successful, latency_sum)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(component_id, bucket) DO NOTHING
`);
function isoFromKuma(value) {
return new Date(value.endsWith("Z") ? value : `${value}Z`).toISOString();
}
function statusCode(message) {
const match = typeof message === "string" ? message.match(/\b(\d{3})\b/) : null;
return match ? Number.parseInt(match[1], 10) : null;
}
function safeError(message, ok) {
if (ok || typeof message !== "string") {
return null;
}
return message.replace(/[\r\n\t]+/g, " ").slice(0, 300);
}
function normalizedUrl(value) {
try {
const url = new URL(value);
url.pathname = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
return url.href.replace(/\/$/, "");
} catch {
return value;
}
}
const rawCutoff = new Date(Date.now() - rawRetentionDays * 86400000);
const kumaRawCutoff = rawCutoff.toISOString().replace("T", " ").replace("Z", "");
const aggregateCutoff = Math.floor((Date.now() - aggregateRetentionDays * 86400000) / 1000);
const imported = [];
target.exec("BEGIN IMMEDIATE");
try {
for (const component of components) {
const monitor = JSON.parse(component.monitor_json);
const candidates = sourceMonitors.filter((candidate) => normalizedUrl(candidate.url) === normalizedUrl(monitor.url));
if (!candidates.length) {
continue;
}
const canonical = candidates[0];
let rawChecks = 0;
for (const heartbeat of heartbeats.all(canonical.id, kumaRawCutoff)) {
const checkedAt = isoFromKuma(heartbeat.time);
const ok = heartbeat.status === 1;
const result = insertCheck.run(
component.id,
checkedAt,
ok ? 1 : 0,
statusCode(heartbeat.msg),
Number.isFinite(heartbeat.ping) ? Math.max(0, Math.round(heartbeat.ping)) : null,
safeError(heartbeat.msg, ok)
);
if (result.changes > 0) {
const latency = Number.isFinite(heartbeat.ping) ? Math.max(0, Math.round(heartbeat.ping)) : 0;
upsertHourly.run(component.id, `${checkedAt.slice(0, 13)}:00:00.000Z`, ok ? 1 : 0, latency);
rawChecks += 1;
}
}
let dailyRows = 0;
for (const daily of dailyStats.all(canonical.id, aggregateCutoff)) {
const total = daily.up + daily.down;
const bucket = new Date(daily.timestamp * 1000).toISOString().slice(0, 10) + "T00:00:00.000Z";
const result = upsertDaily.run(
component.id,
bucket,
total,
daily.up,
Math.max(0, Math.round((daily.ping || 0) * total))
);
dailyRows += result.changes;
}
imported.push({
component: component.id,
monitorId: canonical.id,
monitorName: canonical.name,
rawChecks,
dailyRows
});
}
target.exec("COMMIT");
} catch (error) {
target.exec("ROLLBACK");
throw error;
}
console.log(JSON.stringify({imported}, null, 2));
+209
View File
@@ -0,0 +1,209 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="liooil 的个人主页与 Homelab 动态服务目录。">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#f0c94b">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Xiteng">
<title>liooil / xiteng.site</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/styles.css">
<script src="/app.js?v=7" defer></script>
</head>
<body>
<header class="topbar">
<nav class="shell nav" aria-label="主导航">
<a class="brand" href="#top">liooil / xiteng.site</a>
<div class="nav-links">
<a href="#services">服务</a>
<a href="#infrastructure">基础设施</a>
<a href="#now">Now</a>
<a href="/account">我的凭据</a>
<a href="/admin">管理</a>
<a href="https://github.com/liooil" rel="me">GitHub</a>
</div>
</nav>
</header>
<main id="top">
<section class="hero shell">
<div class="hero-copy">
<p class="eyebrow">PERSONAL INDEX / HOMELAB CATALOG</p>
<h1>一个入口,<br>完整呈现。</h1>
<p class="intro">
服务是否存在与谁能使用它是两件事。这里公开展示全部应用与基础设施;
登录、密钥和内网边界仍由各组件自己负责。
</p>
<div class="actions">
<a class="button primary" href="#services">浏览全部服务</a>
<a class="button" href="#infrastructure">查看系统组成</a>
</div>
</div>
<div class="hero-panel" aria-label="动态目录状态">
<div class="hero-panel-head">
<span>LIVE CATALOG</span>
<span class="live-dot" aria-hidden="true"></span>
</div>
<dl class="hero-stats">
<div>
<dt id="component-count"></dt>
<dd>已声明组件</dd>
</div>
<div>
<dt id="running-count"></dt>
<dd>服务可用</dd>
</div>
<div>
<dt id="restricted-count"></dt>
<dd>受控入口</dd>
</div>
</dl>
<p id="catalog-updated" class="catalog-updated" aria-live="polite">正在读取 Docker Label…</p>
</div>
</section>
<section class="device-status" aria-labelledby="device-heading">
<div class="shell">
<div class="device-heading-row">
<div>
<p class="section-kicker">DEVICE STATUS / <span id="device-hostname">HOMELAB</span></p>
<h2 id="device-heading">设备状态</h2>
</div>
<p id="metrics-updated" class="metrics-updated" aria-live="polite">正在读取主机指标…</p>
</div>
<div class="metric-grid">
<article class="metric-card metric-cpu">
<div class="metric-card-head"><span>CPU</span><span>PROCESSOR</span></div>
<strong id="cpu-value" class="metric-value"></strong>
<progress id="cpu-progress" max="100" value="0">0%</progress>
<p id="cpu-detail" class="metric-detail">正在读取处理器状态…</p>
<p id="cpu-secondary" class="metric-secondary">LOAD —</p>
</article>
<article class="metric-card metric-memory">
<div class="metric-card-head"><span>MEM</span><span>MEMORY</span></div>
<strong id="memory-value" class="metric-value"></strong>
<progress id="memory-progress" max="100" value="0">0%</progress>
<p id="memory-detail" class="metric-detail">正在读取内存状态…</p>
<p id="memory-secondary" class="metric-secondary">AVAILABLE —</p>
</article>
<article class="metric-card metric-disk">
<div class="metric-card-head"><span>DISK</span><span>ROOT FS</span></div>
<strong id="disk-value" class="metric-value"></strong>
<progress id="disk-progress" max="100" value="0">0%</progress>
<p id="disk-detail" class="metric-detail">正在读取磁盘状态…</p>
<p id="disk-secondary" class="metric-secondary">/</p>
</article>
<article class="metric-card metric-gpu">
<div class="metric-card-head"><span>GPU</span><span>NVIDIA</span></div>
<strong id="gpu-value" class="metric-value"></strong>
<progress id="gpu-progress" max="100" value="0">0%</progress>
<p id="gpu-detail" class="metric-detail">正在读取显卡状态…</p>
<p id="gpu-secondary" class="metric-secondary">VRAM —</p>
</article>
</div>
</div>
</section>
<section class="principles">
<div class="shell principle-grid">
<article>
<span class="principle-number">01</span>
<strong>存在是公开信息</strong>
<p>组件名称、用途、技术栈、入口和运行状态对所有访客可见。</p>
</article>
<article>
<span class="principle-number">02</span>
<strong>授权保护数据与操作</strong>
<p>SSO、Access Key、SSH Key 或内网边界在进入组件时生效。</p>
</article>
<article>
<span class="principle-number">03</span>
<strong>Label 决定目录内容</strong>
<p>容器自行声明展示信息;页面不维护硬编码服务清单。</p>
</article>
</div>
</section>
<section id="services" class="catalog-section shell">
<div class="section-heading">
<div>
<p class="section-kicker">APPLICATIONS / ENDPOINTS</p>
<h2>服务目录</h2>
</div>
<p>所有可使用的应用和协议入口。需要认证的服务会在卡片上明确标注。</p>
</div>
<div class="filters" role="group" aria-label="筛选组件">
<button class="filter active" type="button" data-filter="all">全部</button>
<button class="filter" type="button" data-filter="public">无需登录</button>
<button class="filter" type="button" data-filter="restricted">受控访问</button>
<button class="filter" type="button" data-filter="online">服务可用</button>
</div>
<div id="services-catalog" class="catalog" aria-live="polite">
<div class="loading-card">正在发现服务…</div>
</div>
</section>
<section id="infrastructure" class="catalog-section infrastructure-section">
<div class="shell">
<div class="section-heading">
<div>
<p class="section-kicker">EDGE / IDENTITY / DATA / OPS</p>
<h2>基础设施</h2>
</div>
<p>公开系统的组成与职责;不公开密码、Token、私钥和其他秘密值。</p>
</div>
<div id="infrastructure-catalog" class="catalog" aria-live="polite">
<div class="loading-card">正在发现基础设施…</div>
</div>
</div>
</section>
<section id="now" class="now-section shell">
<div class="section-heading">
<div>
<p class="section-kicker">OPERATING MODEL</p>
<h2>Now</h2>
</div>
</div>
<div class="now-grid">
<article class="now-card accent-yellow">
<span>DISCOVERY</span>
<h3>统一入口</h3>
<p><code>xiteng.site</code> 是唯一目录;组件由 Docker Label 自动加入或移除。</p>
</article>
<article class="now-card accent-blue">
<span>ACCESS</span>
<h3>按服务授权</h3>
<p>门户保持公开,受控服务在点击后交给 Authentik 或对应凭据系统。</p>
</article>
<article class="now-card accent-green">
<span>TRANSPARENCY</span>
<h3>架构默认公开</h3>
<p>公开组件、镜像与状态;环境变量、挂载、原始 Label 和秘密值不进入目录 API。</p>
</article>
</div>
</section>
</main>
<footer>
<div class="shell footer-inner">
<span>xiteng.site · <span id="year">2026</span> · liooil</span>
<span>Docker labels → catalog + monitoring</span>
</div>
</footer>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
{
"id": "/",
"name": "Xiteng Portal",
"short_name": "Xiteng",
"description": "动态 Homelab 服务目录、设备状态与健康监控。",
"lang": "zh-CN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#f7efe0",
"theme_color": "#f0c94b",
"categories": ["utilities", "productivity"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "服务目录",
"short_name": "服务",
"url": "/#services",
"icons": [{"src": "/icons/icon-192.png", "sizes": "192x192"}]
},
{
"name": "基础设施",
"short_name": "设施",
"url": "/#infrastructure",
"icons": [{"src": "/icons/icon-192.png", "sizes": "192x192"}]
}
]
}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Expose a small, sanitized snapshot of host CPU, memory, disk, and NVIDIA GPU usage."""
import json
import os
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
PORT = int(os.environ.get("PORT", "8092"))
HOST_PROC = Path(os.environ.get("HOST_PROC", "/host/proc"))
DISK_PATH = Path(os.environ.get("DISK_PATH", "/host/disk"))
CACHE_TTL_SECONDS = float(os.environ.get("CACHE_TTL_SECONDS", "2"))
cache_lock = threading.Lock()
cached_snapshot = None
cached_at = 0.0
previous_cpu = None
def read_text(path):
return path.read_text(encoding="utf-8", errors="replace")
def read_cpu_times():
first_line = read_text(HOST_PROC / "stat").splitlines()[0]
values = [int(value) for value in first_line.split()[1:]]
idle = values[3] + (values[4] if len(values) > 4 else 0)
return sum(values), idle
def cpu_usage_percent():
global previous_cpu
current = read_cpu_times()
if previous_cpu is None:
previous_cpu = current
time.sleep(0.12)
current = read_cpu_times()
total_delta = current[0] - previous_cpu[0]
idle_delta = current[1] - previous_cpu[1]
previous_cpu = current
if total_delta <= 0:
return 0.0
return round(max(0.0, min(100.0, (1 - idle_delta / total_delta) * 100)), 1)
def cpu_info():
cpuinfo = read_text(HOST_PROC / "cpuinfo")
model = "Unknown CPU"
logical_cores = 0
for line in cpuinfo.splitlines():
if line.startswith("processor"):
logical_cores += 1
elif line.startswith("model name") and model == "Unknown CPU":
model = line.split(":", 1)[1].strip()
load_values = read_text(HOST_PROC / "loadavg").split()
return {
"usagePercent": cpu_usage_percent(),
"logicalCores": logical_cores,
"model": model,
"load1": float(load_values[0]),
"load5": float(load_values[1]),
"load15": float(load_values[2]),
}
def memory_info():
values = {}
for line in read_text(HOST_PROC / "meminfo").splitlines():
key, raw_value = line.split(":", 1)
values[key] = int(raw_value.strip().split()[0]) * 1024
total = values["MemTotal"]
available = values["MemAvailable"]
used = total - available
return {
"usedBytes": used,
"availableBytes": available,
"totalBytes": total,
"usagePercent": round(used / total * 100, 1),
}
def root_device():
for line in read_text(HOST_PROC / "1" / "mountinfo").splitlines():
fields = line.split()
if len(fields) < 10 or fields[4] != "/" or "-" not in fields:
continue
separator = fields.index("-")
if len(fields) > separator + 2:
return fields[separator + 2]
return "root filesystem"
def disk_info():
stats = os.statvfs(DISK_PATH)
total = stats.f_blocks * stats.f_frsize
free = stats.f_bfree * stats.f_frsize
available = stats.f_bavail * stats.f_frsize
used = total - free
return {
"device": root_device(),
"mount": "/",
"usedBytes": used,
"availableBytes": available,
"totalBytes": total,
"usagePercent": round(used / total * 100, 1),
}
def optional_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def gpu_info():
query = ",".join([
"index",
"name",
"utilization.gpu",
"memory.used",
"memory.total",
"temperature.gpu",
"power.draw",
"power.limit",
])
try:
result = subprocess.run(
[
"nvidia-smi",
f"--query-gpu={query}",
"--format=csv,noheader,nounits",
],
capture_output=True,
check=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.SubprocessError):
return {"available": False, "devices": []}
devices = []
for line in result.stdout.splitlines():
fields = [field.strip() for field in line.split(",")]
if len(fields) != 8:
continue
devices.append({
"index": int(fields[0]),
"name": fields[1],
"utilizationPercent": optional_float(fields[2]),
"memoryUsedMiB": optional_float(fields[3]),
"memoryTotalMiB": optional_float(fields[4]),
"temperatureCelsius": optional_float(fields[5]),
"powerDrawWatts": optional_float(fields[6]),
"powerLimitWatts": optional_float(fields[7]),
})
return {"available": bool(devices), "devices": devices}
def host_name():
hostname_path = Path("/host/hostname")
if hostname_path.exists():
return read_text(hostname_path).strip()
return "homelab"
def collect_snapshot():
global cached_snapshot, cached_at
now = time.monotonic()
with cache_lock:
if cached_snapshot is not None and now - cached_at < CACHE_TTL_SECONDS:
return cached_snapshot
cached_snapshot = {
"generatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"hostname": host_name(),
"cpu": cpu_info(),
"memory": memory_info(),
"disk": disk_info(),
"gpu": gpu_info(),
}
cached_at = time.monotonic()
return cached_snapshot
class MetricsHandler(BaseHTTPRequestHandler):
def send_json(self, status_code, payload):
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
self.send_response(status_code)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("X-Content-Type-Options", "nosniff")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/healthz":
self.send_json(200, {"status": "ok"})
return
if self.path != "/metrics":
self.send_json(404, {"error": "Not found"})
return
try:
self.send_json(200, collect_snapshot())
except Exception as error:
print(f"metrics collection failed: {error}", flush=True)
self.send_json(503, {"error": "Metrics unavailable"})
def log_message(self, message_format, *args):
return
ThreadingHTTPServer(("0.0.0.0", PORT), MetricsHandler).serve_forever()
+852
View File
@@ -0,0 +1,852 @@
import http from "node:http";
import {mkdirSync} from "node:fs";
import {DatabaseSync} from "node:sqlite";
const port = Number.parseInt(process.env.PORT || "8091", 10);
const dockerSocket = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
const databasePath = process.env.DATABASE_PATH || "/data/registry.db";
const discoveryIntervalMs = boundedInteger(process.env.DISCOVERY_INTERVAL_MS, 5000, 2000, 60000);
const missingRetentionDays = boundedInteger(process.env.MISSING_RETENTION_DAYS, 30, 1, 3650);
const rawRetentionDays = boundedInteger(process.env.RAW_RETENTION_DAYS, 30, 1, 3650);
const aggregateRetentionDays = boundedInteger(process.env.AGGREGATE_RETENTION_DAYS, 365, 30, 3650);
const monitorConcurrency = boundedInteger(process.env.MONITOR_CONCURRENCY, 4, 1, 32);
const adminProxyHeader = process.env.ADMIN_PROXY_HEADER || "X-Portal-Admin";
const labelPrefix = "xiteng.site.component.";
const allowedFields = new Set([
"enabled",
"name",
"description",
"section",
"category",
"url",
"endpoint",
"access",
"access-label",
"icon",
"icon-url",
"navigation",
"portal-link",
"accent",
"order",
"monitor.enabled",
"monitor.url",
"monitor.interval",
"monitor.failures",
"monitor.timeout",
"monitor.accept"
]);
const allowedSections = new Set(["services", "infrastructure"]);
const allowedAccents = new Set(["red", "green", "yellow", "blue", "ink"]);
const activeChecks = new Set();
let discoveryRunning = false;
let lastDiscoveryAt = null;
mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true});
const database = new DatabaseSync(databasePath);
database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS registry_component (
id TEXT PRIMARY KEY,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
lifecycle TEXT NOT NULL DEFAULT 'active',
missing_since TEXT,
archived_at TEXT,
archive_reason TEXT,
metadata_json TEXT NOT NULL,
monitor_json TEXT,
monitor_state TEXT NOT NULL DEFAULT 'unmonitored',
monitor_paused INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_status_code INTEGER,
last_latency_ms INTEGER,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS monitor_check (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
checked_at TEXT NOT NULL,
ok INTEGER NOT NULL,
status_code INTEGER,
latency_ms INTEGER,
error TEXT
);
CREATE INDEX IF NOT EXISTS monitor_check_component_time
ON monitor_check(component_id, checked_at);
CREATE UNIQUE INDEX IF NOT EXISTS monitor_check_unique
ON monitor_check(component_id, checked_at);
CREATE TABLE IF NOT EXISTS monitor_incident (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
started_at TEXT NOT NULL,
ended_at TEXT,
cause TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS monitor_incident_open
ON monitor_incident(component_id) WHERE ended_at IS NULL;
CREATE TABLE IF NOT EXISTS monitor_hourly (
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
bucket TEXT NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
successful INTEGER NOT NULL DEFAULT 0,
latency_sum INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (component_id, bucket)
);
CREATE TABLE IF NOT EXISTS monitor_daily (
component_id TEXT NOT NULL REFERENCES registry_component(id) ON DELETE CASCADE,
bucket TEXT NOT NULL,
total INTEGER NOT NULL DEFAULT 0,
successful INTEGER NOT NULL DEFAULT 0,
latency_sum INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (component_id, bucket)
);
`);
const selectComponent = database.prepare("SELECT * FROM registry_component WHERE id = ?");
const selectComponents = database.prepare("SELECT * FROM registry_component ORDER BY id");
const insertComponent = database.prepare(`
INSERT INTO registry_component (
id, first_seen, last_seen, lifecycle, metadata_json, monitor_json, monitor_state
) VALUES (?, ?, ?, 'active', ?, ?, ?)
`);
const updateDiscoveredComponent = database.prepare(`
UPDATE registry_component
SET last_seen = ?,
lifecycle = ?,
missing_since = NULL,
archived_at = CASE WHEN archive_reason = 'manual' THEN archived_at ELSE NULL END,
archive_reason = CASE WHEN archive_reason = 'manual' THEN archive_reason ELSE NULL END,
metadata_json = ?,
monitor_json = ?,
monitor_state = ?,
monitor_paused = ?
WHERE id = ?
`);
const markMissing = database.prepare(`
UPDATE registry_component
SET lifecycle = 'missing', missing_since = ?
WHERE id = ? AND lifecycle = 'active'
`);
const markArchivedByRetention = database.prepare(`
UPDATE registry_component
SET lifecycle = 'archived', archived_at = ?, archive_reason = 'retention',
monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'paused' END,
monitor_paused = CASE WHEN monitor_json IS NULL THEN 0 ELSE 1 END
WHERE id = ? AND lifecycle = 'missing'
`);
const insertCheck = database.prepare(`
INSERT INTO monitor_check (component_id, checked_at, ok, status_code, latency_ms, error)
VALUES (?, ?, ?, ?, ?, ?)
`);
const upsertHourly = database.prepare(`
INSERT INTO monitor_hourly (component_id, bucket, total, successful, latency_sum)
VALUES (?, ?, 1, ?, ?)
ON CONFLICT(component_id, bucket) DO UPDATE SET
total = total + 1,
successful = successful + excluded.successful,
latency_sum = latency_sum + excluded.latency_sum
`);
const upsertDaily = database.prepare(`
INSERT INTO monitor_daily (component_id, bucket, total, successful, latency_sum)
VALUES (?, ?, 1, ?, ?)
ON CONFLICT(component_id, bucket) DO UPDATE SET
total = total + 1,
successful = successful + excluded.successful,
latency_sum = latency_sum + excluded.latency_sum
`);
const uptimeSince = database.prepare(`
SELECT COUNT(*) AS total, COALESCE(SUM(ok), 0) AS successful
FROM monitor_check
WHERE component_id = ? AND checked_at >= ?
`);
const openIncident = database.prepare(`
SELECT started_at FROM monitor_incident
WHERE component_id = ? AND ended_at IS NULL
`);
function boundedInteger(value, fallback, minimum, maximum) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(minimum, Math.min(maximum, parsed));
}
function dockerGet(pathname) {
return new Promise((resolve, reject) => {
const request = http.request({
socketPath: dockerSocket,
path: pathname,
method: "GET",
headers: {"Accept": "application/json"}
}, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => {
const body = Buffer.concat(chunks).toString("utf8");
if ((response.statusCode || 500) >= 400) {
reject(new Error(`Docker API ${response.statusCode}`));
return;
}
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
});
request.setTimeout(3000, () => request.destroy(new Error("Docker API timeout")));
request.on("error", reject);
request.end();
});
}
function limitedString(value, fallback = "", limit = 500) {
return typeof value === "string" ? value.trim().slice(0, limit) : fallback;
}
function safeUrl(value) {
const candidate = limitedString(value);
if (!candidate) {
return "";
}
try {
const url = new URL(candidate);
if (url.username || url.password) {
return "";
}
return ["http:", "https:"].includes(url.protocol) ? url.href : "";
} catch {
return "";
}
}
function healthFromStatus(status) {
if (status.includes("(unhealthy)")) {
return "unhealthy";
}
if (status.includes("(healthy)")) {
return "healthy";
}
if (status.includes("(health: starting)")) {
return "starting";
}
return "none";
}
function instanceState(container) {
const state = limitedString(container.State, "unknown").toLowerCase();
const health = healthFromStatus(limitedString(container.Status));
return health === "unhealthy" ? "unhealthy" : state;
}
function aggregateStatus(instances) {
const states = instances.map((instance) => instance.state);
if (states.length > 0 && states.every((state) => state === "running")) {
return "running";
}
if (states.includes("unhealthy")) {
return "unhealthy";
}
if (states.includes("restarting")) {
return "restarting";
}
if (states.includes("running")) {
return "degraded";
}
for (const state of ["dead", "exited", "paused", "created"]) {
if (states.includes(state)) {
return state;
}
}
return "unknown";
}
function definitionsFromLabels(labels) {
const definitions = new Map();
for (const [key, value] of Object.entries(labels || {})) {
if (!key.startsWith(labelPrefix)) {
continue;
}
const remainder = key.slice(labelPrefix.length);
const separator = remainder.indexOf(".");
if (separator <= 0) {
continue;
}
const id = remainder.slice(0, separator);
const field = remainder.slice(separator + 1);
if (!/^[a-z0-9][a-z0-9-]*$/.test(id) || !allowedFields.has(field)) {
continue;
}
if (!definitions.has(id)) {
definitions.set(id, {});
}
definitions.get(id)[field] = limitedString(value);
}
return definitions;
}
function publicDefinition(id, definition) {
const order = Number.parseInt(definition.order || "999", 10);
return {
id,
name: limitedString(definition.name, id),
description: limitedString(definition.description),
section: allowedSections.has(definition.section) ? definition.section : "infrastructure",
category: limitedString(definition.category, "其他"),
url: safeUrl(definition.url),
endpoint: limitedString(definition.endpoint, "", 160),
access: limitedString(definition.access, "internal", 40),
accessLabel: limitedString(definition["access-label"], "受控访问", 100),
icon: limitedString(definition.icon, id.slice(0, 2).toUpperCase(), 8),
iconUrl: safeUrl(definition["icon-url"]),
navigation: ["new-tab", "same-tab", "endpoint"].includes(definition.navigation) ? definition.navigation : (definition.url ? "new-tab" : "endpoint"),
portalLink: ["embedded", "native", "none"].includes(definition["portal-link"]) ? definition["portal-link"] : "none",
accent: allowedAccents.has(definition.accent) ? definition.accent : "ink",
order: Number.isFinite(order) ? order : 999
};
}
function monitorDefinition(definition) {
if (definition["monitor.enabled"]?.toLowerCase() !== "true") {
return null;
}
const url = safeUrl(definition["monitor.url"]);
if (!url) {
return null;
}
return {
type: "http",
url,
interval: boundedInteger(definition["monitor.interval"], 60, 10, 86400),
failures: boundedInteger(definition["monitor.failures"], 3, 1, 20),
timeout: boundedInteger(definition["monitor.timeout"], 10, 1, 60),
accept: limitedString(definition["monitor.accept"], "200-299", 80)
};
}
function parseJson(value, fallback) {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
async function discoverDockerComponents() {
const containers = await dockerGet("/containers/json?all=true");
const components = new Map();
for (const container of containers) {
const labels = container.Labels || {};
for (const [id, definition] of definitionsFromLabels(labels)) {
if (definition.enabled?.toLowerCase() !== "true") {
continue;
}
const metadata = publicDefinition(id, definition);
const instance = {
container: limitedString(container.Names?.[0] || "", "", 160).replace(/^\//, ""),
service: limitedString(labels["com.docker.compose.service"], "docker", 160),
project: limitedString(labels["com.docker.compose.project"], "", 160),
image: limitedString(container.Image, "", 300),
state: instanceState(container),
health: healthFromStatus(limitedString(container.Status))
};
if (!components.has(id)) {
components.set(id, {...metadata, monitor: monitorDefinition(definition), instances: []});
}
components.get(id).instances.push(instance);
}
}
for (const component of components.values()) {
const primary = component.instances[0];
component.status = aggregateStatus(component.instances);
component.instanceCount = component.instances.length;
component.container = primary.container;
component.service = primary.service;
component.project = primary.project;
component.image = primary.image;
}
return components;
}
function reconcileComponent(id, discovered, now) {
const existing = selectComponent.get(id);
const metadata = {...discovered};
delete metadata.monitor;
delete metadata.instances;
const metadataJson = JSON.stringify(metadata);
const monitorJson = discovered.monitor ? JSON.stringify(discovered.monitor) : null;
if (!existing) {
insertComponent.run(
id,
now,
now,
metadataJson,
monitorJson,
monitorJson ? "pending" : "unmonitored"
);
return;
}
const manuallyArchived = existing.lifecycle === "archived" && existing.archive_reason === "manual";
const monitorChanged = existing.monitor_json !== monitorJson;
let monitorState = existing.monitor_state;
let monitorPaused = existing.monitor_paused;
if (!monitorJson) {
monitorState = "unmonitored";
monitorPaused = 0;
} else if (manuallyArchived) {
monitorState = "paused";
monitorPaused = 1;
} else if (monitorChanged || existing.lifecycle === "archived") {
monitorState = "pending";
monitorPaused = 0;
}
updateDiscoveredComponent.run(
now,
manuallyArchived ? "archived" : "active",
metadataJson,
monitorJson,
monitorState,
monitorPaused,
id
);
if (monitorChanged) {
database.prepare(`
UPDATE registry_component
SET consecutive_failures = 0, last_checked_at = NULL, last_status_code = NULL,
last_latency_ms = NULL, last_error = NULL
WHERE id = ?
`).run(id);
database.prepare(`
UPDATE monitor_incident SET ended_at = ?
WHERE component_id = ? AND ended_at IS NULL
`).run(now, id);
}
}
async function reconcileDiscovery() {
if (discoveryRunning) {
return;
}
discoveryRunning = true;
try {
const now = new Date().toISOString();
const discovered = await discoverDockerComponents();
database.exec("BEGIN IMMEDIATE");
try {
for (const [id, component] of discovered) {
reconcileComponent(id, component, now);
}
for (const existing of selectComponents.all()) {
if (discovered.has(existing.id) || existing.lifecycle === "archived") {
continue;
}
if (existing.lifecycle === "active") {
markMissing.run(now, existing.id);
continue;
}
if (existing.lifecycle === "missing" && existing.missing_since) {
const missingForMs = Date.now() - new Date(existing.missing_since).getTime();
if (missingForMs >= missingRetentionDays * 86400000) {
markArchivedByRetention.run(now, existing.id);
}
}
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
lastDiscoveryAt = now;
} catch (error) {
console.error("Docker discovery failed", error.message);
} finally {
discoveryRunning = false;
}
}
function statusAccepted(statusCode, expression) {
for (const part of expression.split(",")) {
const match = part.trim().match(/^(\d{3})(?:-(\d{3}))?$/);
if (!match) {
continue;
}
const minimum = Number.parseInt(match[1], 10);
const maximum = Number.parseInt(match[2] || match[1], 10);
if (statusCode >= minimum && statusCode <= maximum) {
return true;
}
}
return false;
}
function safeError(error) {
const message = error instanceof Error ? error.message : String(error);
return limitedString(message.replace(/[\r\n\t]+/g, " "), "probe failed", 300);
}
function updateAggregate(componentId, checkedAt, ok, latencyMs) {
const hourBucket = `${checkedAt.slice(0, 13)}:00:00.000Z`;
const dayBucket = `${checkedAt.slice(0, 10)}T00:00:00.000Z`;
upsertHourly.run(componentId, hourBucket, ok ? 1 : 0, latencyMs || 0);
upsertDaily.run(componentId, dayBucket, ok ? 1 : 0, latencyMs || 0);
}
function recordCheck(componentId, monitor, result) {
const existing = selectComponent.get(componentId);
if (!existing) {
return;
}
const nextFailures = result.ok ? 0 : existing.consecutive_failures + 1;
const nextState = result.ok ? "up" : nextFailures >= monitor.failures ? "down" : "degraded";
database.exec("BEGIN IMMEDIATE");
try {
insertCheck.run(
componentId,
result.checkedAt,
result.ok ? 1 : 0,
result.statusCode,
result.latencyMs,
result.error
);
updateAggregate(componentId, result.checkedAt, result.ok, result.latencyMs);
database.prepare(`
UPDATE registry_component
SET monitor_state = ?, consecutive_failures = ?, last_checked_at = ?,
last_status_code = ?, last_latency_ms = ?, last_error = ?
WHERE id = ?
`).run(
nextState,
nextFailures,
result.checkedAt,
result.statusCode,
result.latencyMs,
result.error,
componentId
);
if (nextState === "down" && existing.monitor_state !== "down") {
database.prepare(`
INSERT OR IGNORE INTO monitor_incident (component_id, started_at, cause)
VALUES (?, ?, ?)
`).run(componentId, result.checkedAt, result.error || `HTTP ${result.statusCode || "error"}`);
}
if (result.ok) {
database.prepare(`
UPDATE monitor_incident SET ended_at = ?
WHERE component_id = ? AND ended_at IS NULL
`).run(result.checkedAt, componentId);
}
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
}
async function checkComponent(row, monitor) {
const checkedAt = new Date().toISOString();
const startedAt = performance.now();
let statusCode = null;
let ok = false;
let errorMessage = null;
try {
const response = await fetch(monitor.url, {
method: "GET",
redirect: "follow",
headers: {
"Accept": "text/html,application/json;q=0.9,*/*;q=0.1",
"User-Agent": "Xiteng-Portal-Monitor/1.0"
},
signal: AbortSignal.timeout(monitor.timeout * 1000)
});
statusCode = response.status;
ok = statusAccepted(statusCode, monitor.accept);
if (!ok) {
errorMessage = `HTTP ${statusCode}`;
}
if (response.body) {
await response.body.cancel();
}
} catch (error) {
errorMessage = safeError(error);
}
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
recordCheck(row.id, monitor, {
checkedAt,
ok,
statusCode,
latencyMs,
error: errorMessage
});
}
function scheduleDueChecks() {
if (activeChecks.size >= monitorConcurrency) {
return;
}
const now = Date.now();
const candidates = selectComponents.all().filter((row) => {
if (!row.monitor_json || row.monitor_paused || !["active", "missing"].includes(row.lifecycle)) {
return false;
}
const monitor = parseJson(row.monitor_json, null);
if (!monitor) {
return false;
}
const lastChecked = row.last_checked_at ? new Date(row.last_checked_at).getTime() : 0;
return !activeChecks.has(row.id) && now - lastChecked >= monitor.interval * 1000;
});
for (const row of candidates.slice(0, monitorConcurrency - activeChecks.size)) {
const monitor = parseJson(row.monitor_json, null);
activeChecks.add(row.id);
checkComponent(row, monitor)
.catch((error) => console.error(`Monitor ${row.id} failed`, error.message))
.finally(() => activeChecks.delete(row.id));
}
}
function availability(componentId, hours) {
const since = new Date(Date.now() - hours * 3600000).toISOString();
const result = uptimeSince.get(componentId, since);
if (!result || result.total === 0) {
return null;
}
return Math.round((result.successful / result.total) * 10000) / 100;
}
function publicComponent(row, includeAdmin = false) {
const metadata = parseJson(row.metadata_json, {id: row.id, name: row.id});
const monitor = parseJson(row.monitor_json, null);
const incident = openIncident.get(row.id);
const component = {
...metadata,
lifecycle: row.lifecycle,
status: row.lifecycle === "active" ? metadata.status : row.lifecycle,
monitor: {
enabled: Boolean(monitor),
status: monitor
? row.monitor_paused ? "paused" : row.monitor_state
: "unmonitored",
checkedAt: row.last_checked_at,
responseTimeMs: row.last_latency_ms,
statusCode: row.last_status_code,
consecutiveFailures: row.consecutive_failures,
uptime24h: monitor ? availability(row.id, 24) : null,
incidentSince: incident?.started_at || null
}
};
if (includeAdmin) {
component.firstSeen = row.first_seen;
component.lastSeen = row.last_seen;
component.missingSince = row.missing_since;
component.archivedAt = row.archived_at;
component.archiveReason = row.archive_reason;
component.monitor.paused = Boolean(row.monitor_paused);
component.monitor.error = row.last_error;
}
return component;
}
function componentPayload(includeArchived = false, includeAdmin = false) {
const components = selectComponents.all()
.filter((row) => includeArchived || row.lifecycle !== "archived")
.map((row) => publicComponent(row, includeAdmin))
.sort((left, right) => {
if (left.section !== right.section) {
return left.section.localeCompare(right.section);
}
if (left.order !== right.order) {
return left.order - right.order;
}
return left.name.localeCompare(right.name, "zh-CN");
});
return {
generatedAt: new Date().toISOString(),
discoveredAt: lastDiscoveryAt,
components
};
}
function pruneHistory() {
const rawCutoff = new Date(Date.now() - rawRetentionDays * 86400000).toISOString();
const aggregateCutoff = new Date(Date.now() - aggregateRetentionDays * 86400000).toISOString();
database.prepare("DELETE FROM monitor_check WHERE checked_at < ?").run(rawCutoff);
database.prepare("DELETE FROM monitor_hourly WHERE bucket < ?").run(aggregateCutoff);
database.prepare("DELETE FROM monitor_daily WHERE bucket < ?").run(aggregateCutoff);
}
function isAdminProxy(request) {
return request.headers[adminProxyHeader.toLowerCase()] === "1";
}
function adminAction(id, action) {
const row = selectComponent.get(id);
if (!row) {
return {statusCode: 404, payload: {error: "Component not found"}};
}
const now = new Date().toISOString();
if (action === "archive") {
database.prepare(`
UPDATE registry_component
SET lifecycle = 'archived', archived_at = ?, archive_reason = 'manual',
monitor_paused = CASE WHEN monitor_json IS NULL THEN 0 ELSE 1 END,
monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'paused' END
WHERE id = ?
`).run(now, id);
} else if (action === "restore") {
const recentlySeen = Date.now() - new Date(row.last_seen).getTime() < discoveryIntervalMs * 3;
database.prepare(`
UPDATE registry_component
SET lifecycle = ?, missing_since = ?, archived_at = NULL, archive_reason = NULL,
monitor_paused = 0,
monitor_state = CASE WHEN monitor_json IS NULL THEN 'unmonitored' ELSE 'pending' END
WHERE id = ?
`).run(recentlySeen ? "active" : "missing", recentlySeen ? null : now, id);
} else if (action === "pause") {
if (!row.monitor_json) {
return {statusCode: 409, payload: {error: "Monitoring is not configured"}};
}
database.prepare(`
UPDATE registry_component SET monitor_paused = 1, monitor_state = 'paused' WHERE id = ?
`).run(id);
} else if (action === "resume") {
if (!row.monitor_json) {
return {statusCode: 409, payload: {error: "Monitoring is not configured"}};
}
database.prepare(`
UPDATE registry_component
SET monitor_paused = 0, monitor_state = 'pending', consecutive_failures = 0
WHERE id = ?
`).run(id);
} else if (action === "purge") {
const recentlySeen = Date.now() - new Date(row.last_seen).getTime() < discoveryIntervalMs * 3;
if (row.lifecycle !== "missing" && recentlySeen) {
return {statusCode: 409, payload: {error: "Remove or disable the component Label before purging"}};
}
database.prepare("DELETE FROM registry_component WHERE id = ?").run(id);
return {statusCode: 200, payload: {status: "purged", id}};
} else {
return {statusCode: 404, payload: {error: "Unknown action"}};
}
return {
statusCode: 200,
payload: {status: "ok", component: publicComponent(selectComponent.get(id), true)}
};
}
function sendJson(response, statusCode, payload, extraHeaders = {}) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
"X-Content-Type-Options": "nosniff",
...extraHeaders
});
response.end(body);
}
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url || "/", "http://localhost").pathname;
if (request.method === "GET" && pathname === "/healthz") {
sendJson(response, 200, {
status: "ok",
discovery: lastDiscoveryAt ? "ready" : "starting",
activeChecks: activeChecks.size
});
return;
}
if (request.method === "GET" && pathname === "/components") {
sendJson(response, 200, componentPayload(false, false));
return;
}
if (pathname.startsWith("/admin/") && !isAdminProxy(request)) {
sendJson(response, 403, {error: "Forbidden"});
return;
}
if (request.method === "GET" && pathname === "/admin/components") {
sendJson(response, 200, componentPayload(true, true));
return;
}
const actionMatch = pathname.match(/^\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/);
if (request.method === "POST" && actionMatch) {
const result = adminAction(actionMatch[1], actionMatch[2]);
sendJson(response, result.statusCode, result.payload);
return;
}
if (!["GET", "POST"].includes(request.method || "")) {
sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, POST"});
return;
}
sendJson(response, 404, {error: "Not found"});
});
await reconcileDiscovery();
pruneHistory();
setInterval(reconcileDiscovery, discoveryIntervalMs);
setInterval(scheduleDueChecks, 2000);
setInterval(pruneHistory, 6 * 3600000);
server.listen(port, "0.0.0.0", () => {
console.log(`component registry and monitor listening on :${port}`);
});
+394
View File
@@ -0,0 +1,394 @@
import {createAuthentikAdmin} from "./authentik.mjs";
import {createReadStream, readFileSync} from "node:fs";
import {stat} from "node:fs/promises";
import http from "node:http";
const port = Number.parseInt(process.env.PORT || "8080", 10);
const registryUrl = process.env.REGISTRY_URL || "http://xiteng-site-registry:8091/components";
const registryAdminUrl = process.env.REGISTRY_ADMIN_URL || "http://xiteng-site-registry:8091/admin";
const metricsUrl = process.env.METRICS_URL || "http://xiteng-site-metrics:8092/metrics";
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
const providerTestUrl = process.env.PROVIDER_TEST_URL || "http://xiteng-chat:3000/api/provider-test";
const keyVaultTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
const authentikUrl = process.env.AUTHENTIK_URL || "https://auth.xiteng.site";
const authentikTokenFile = process.env.AUTHENTIK_TOKEN_FILE || "/run/authentik-secrets/portal_api_token";
const identityAuditPath = process.env.IDENTITY_AUDIT_PATH || "/data/identity-audit.jsonl";
const staticFiles = new Map([
["/", {path: "/app/index.html", type: "text/html; charset=utf-8"}],
["/index.html", {path: "/app/index.html", type: "text/html; charset=utf-8"}],
["/styles.css", {path: "/app/styles.css", type: "text/css; charset=utf-8"}],
["/app.js", {path: "/app/app.js", type: "text/javascript; charset=utf-8"}],
["/sw.js", {path: "/app/sw.js", type: "text/javascript; charset=utf-8", cacheControl: "no-cache"}],
["/manifest.webmanifest", {path: "/app/manifest.webmanifest", type: "application/manifest+json; charset=utf-8"}],
["/favicon.svg", {path: "/app/favicon.svg", type: "image/svg+xml"}],
["/favicon.ico", {path: "/app/favicon.ico", type: "image/x-icon"}],
["/icons/favicon-32.png", {path: "/app/icons/favicon-32.png", type: "image/png"}],
["/icons/apple-touch-icon.png", {path: "/app/icons/apple-touch-icon.png", type: "image/png"}],
["/icons/icon-192.png", {path: "/app/icons/icon-192.png", type: "image/png"}],
["/icons/icon-512.png", {path: "/app/icons/icon-512.png", type: "image/png"}],
["/icons/icon-maskable-512.png", {path: "/app/icons/icon-maskable-512.png", type: "image/png"}],
["/icons/services/chat.svg", {path: "/app/icons/services/chat.svg", type: "image/svg+xml"}],
["/admin", {path: "/app/admin.html", type: "text/html; charset=utf-8"}],
["/admin/", {path: "/app/admin.html", type: "text/html; charset=utf-8"}],
["/admin.js", {path: "/app/admin.js", type: "text/javascript; charset=utf-8"}],
["/account", {path: "/app/account.html", type: "text/html; charset=utf-8"}],
["/account/", {path: "/app/account.html", type: "text/html; charset=utf-8"}],
["/account.js", {path: "/app/account.js", type: "text/javascript; charset=utf-8"}]
]);
const securityHeaders = {
"Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data: https://www.gravatar.com https://seccdn.libravatar.org https://cdn.jsdelivr.net https://cdn.simpleicons.org; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
};
function sendJson(response, statusCode, payload, extraHeaders = {}) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
...extraHeaders
});
response.end(body);
}
function readOptionalSecret(path) {
try {
return readFileSync(path, "utf8").trim();
} catch {
return null;
}
}
const keyVaultToken = readOptionalSecret(keyVaultTokenFile);
const authentikAdmin = createAuthentikAdmin({
baseUrl: authentikUrl,
token: readOptionalSecret(authentikTokenFile),
auditPath: identityAuditPath,
adminUsername
});
async function readBody(request, maximum = 65536) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maximum) {
const error = new Error("Request body too large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function parseJson(body) {
try {
return body.length ? JSON.parse(body.toString("utf8")) : {};
} catch {
const error = new Error("Invalid JSON body");
error.statusCode = 400;
throw error;
}
}
async function proxyJson(request, response, upstreamUrl, unavailableMessage, headers = {}, timeoutMs = 5000) {
try {
const method = request.method === "POST" ? "POST" : "GET";
const body = method === "POST" ? await readBody(request) : undefined;
const upstream = await fetch(upstreamUrl, {
method,
body: body?.length ? body : undefined,
headers: {
"Accept": "application/json",
...(body?.length ? {"Content-Type": "application/json"} : {}),
...headers
},
signal: AbortSignal.timeout(timeoutMs)
});
const upstreamBody = await upstream.text();
response.writeHead(upstream.status, {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(upstreamBody)
});
response.end(upstreamBody);
} catch (error) {
console.error(unavailableMessage, error.message);
sendJson(response, 503, {error: unavailableMessage});
}
}
async function serveStatic(request, response, file) {
try {
const metadata = await stat(file.path);
response.writeHead(200, {
...securityHeaders,
"Cache-Control": file.cacheControl || "no-cache",
"Content-Type": file.type,
"Content-Length": metadata.size
});
if (request.method === "HEAD") {
response.end();
return;
}
createReadStream(file.path).pipe(response);
} catch {
sendJson(response, 404, {error: "Not found"});
}
}
function header(request, name) {
const value = request.headers[name];
return typeof value === "string" ? value.trim() : "";
}
function identityFromRequest(request) {
const username = header(request, "x-authentik-username");
return {
issuer: authentikIssuer,
sub: header(request, "x-authentik-uid") || username,
username,
email: header(request, "x-authentik-email"),
admin: username === adminUsername,
provider: "authentik"
};
}
function isAuthenticated(request) {
return header(request, "x-portal-authenticated") === "1" && Boolean(identityFromRequest(request).username);
}
function validOrigin(request) {
return request.headers.origin === "https://xiteng.site";
}
function requireMutationOrigin(request, response) {
if (!validOrigin(request)) {
sendJson(response, 403, {error: "Invalid request origin"});
return false;
}
return true;
}
async function proxyKeyVault(request, response, upstreamPath) {
if (!keyVaultToken) {
sendJson(response, 503, {error: "Key Vault is not initialized"});
return;
}
try {
const body = request.method === "POST" ? await readBody(request, 1048576) : Buffer.alloc(0);
const actor = identityFromRequest(request);
const upstream = await fetch(new URL(upstreamPath, keyVaultUrl), {
method: request.method,
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${keyVaultToken}`,
...(body.length ? {"Content-Type": "application/json"} : {}),
"X-Key-Vault-Actor-Issuer": actor.issuer,
"X-Key-Vault-Actor-Sub": actor.sub,
"X-Key-Vault-Actor-Username": actor.username
},
body: body.length ? body : undefined,
signal: AbortSignal.timeout(10000)
});
const upstreamBody = await upstream.text();
response.writeHead(upstream.status, {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(upstreamBody)
});
response.end(upstreamBody);
} catch (error) {
console.error("Key Vault unavailable", error.message);
sendJson(response, 503, {error: "Key Vault unavailable"});
}
}
function vaultUpstreamPath(pathname, admin, searchParams) {
const prefix = admin ? "/api/admin/vault" : "/api/account/vault";
const suffix = pathname.slice(prefix.length);
if (suffix === "/providers") return "/v1/providers";
if (/^\/providers\/[a-z0-9._-]+\/delete$/.test(suffix)) return `/v1${suffix}`;
if (suffix === "/credentials") {
return `/v1/credentials${admin && searchParams.get("scope") !== "own" ? "?scope=all" : ""}`;
}
if (suffix === "/audit") return `/v1/audit${admin ? "?scope=all&limit=200" : "?limit=100"}`;
if (/^\/credentials\/[0-9a-f-]+\/(replace|verify|delete)$/.test(suffix)) return `/v1${suffix}`;
return null;
}
const server = http.createServer(async (request, response) => {
try {
if (!request.url) {
sendJson(response, 400, {error: "Bad request"});
return;
}
const url = new URL(request.url, "http://localhost");
const pathname = url.pathname;
if (request.method === "GET" && pathname === "/healthz") {
sendJson(response, 200, {status: "ok"});
return;
}
if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/components") {
await proxyJson(request, response, registryUrl, "Component registry unavailable");
return;
}
if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/metrics") {
await proxyJson(request, response, metricsUrl, "Host metrics unavailable");
return;
}
const isAdminPath = pathname === "/admin"
|| pathname === "/admin/"
|| pathname === "/admin.js"
|| pathname.startsWith("/api/admin/");
const isAccountPath = pathname === "/account"
|| pathname === "/account/"
|| pathname === "/account.js"
|| pathname.startsWith("/api/account/");
if ((isAdminPath || isAccountPath) && !isAuthenticated(request)) {
sendJson(response, 403, {error: "Authenticated user required"});
return;
}
if (isAdminPath && !identityFromRequest(request).admin) {
sendJson(response, 403, {error: `Administrator ${adminUsername} required`});
return;
}
if (request.method === "GET" && ["/api/admin/session", "/api/account/session"].includes(pathname)) {
sendJson(response, 200, {identity: identityFromRequest(request), administrator: adminUsername});
return;
}
if (pathname === "/api/admin/identity" && request.method === "GET") {
sendJson(response, 200, await authentikAdmin.summary(url.searchParams.get("search") || ""));
return;
}
if (pathname.startsWith("/api/admin/identity/") && request.method === "POST") {
if (!requireMutationOrigin(request, response)) {
return;
}
const result = await authentikAdmin.mutate(
pathname,
parseJson(await readBody(request)),
identityFromRequest(request).username
);
sendJson(response, 200, {result});
return;
}
if (pathname === "/api/account/identity" && request.method === "GET") {
sendJson(response, 200, await authentikAdmin.accountSummary(identityFromRequest(request).username));
return;
}
if (pathname === "/api/account/provider-test" && request.method === "POST") {
if (!requireMutationOrigin(request, response)) return;
const identity = identityFromRequest(request);
await proxyJson(request, response, providerTestUrl, "Provider connectivity test unavailable", {
"X-Authentik-Username": identity.username,
"X-Authentik-Uid": identity.sub,
"X-Authentik-Email": identity.email
}, 20000);
return;
}
if (pathname.startsWith("/api/account/") && request.method === "POST" && !pathname.startsWith("/api/account/vault")) {
if (!requireMutationOrigin(request, response)) return;
const result = await authentikAdmin.accountMutate(
pathname,
parseJson(await readBody(request)),
identityFromRequest(request).username
);
sendJson(response, 200, {result});
return;
}
if (pathname.startsWith("/api/admin/vault") || pathname.startsWith("/api/account/vault")) {
const admin = pathname.startsWith("/api/admin/vault");
const upstreamPath = vaultUpstreamPath(pathname, admin, url.searchParams);
if (!upstreamPath) {
sendJson(response, 404, {error: "Vault action not found"});
return;
}
if (request.method === "POST" && !requireMutationOrigin(request, response)) {
return;
}
if (!["GET", "POST"].includes(request.method || "")) {
sendJson(response, 405, {error: "Method not allowed"});
return;
}
await proxyKeyVault(request, response, upstreamPath);
return;
}
if (pathname === "/api/admin/components" && request.method === "GET") {
await proxyJson(
request,
response,
`${registryAdminUrl}/components`,
"Registry administration unavailable",
{"X-Portal-Admin": "1"}
);
return;
}
const adminAction = pathname.match(/^\/api\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/);
if (adminAction && request.method === "POST") {
if (!requireMutationOrigin(request, response)) {
return;
}
await proxyJson(
request,
response,
`${registryAdminUrl}/components/${adminAction[1]}/${adminAction[2]}`,
"Registry administration unavailable",
{"X-Portal-Admin": "1"}
);
return;
}
const file = staticFiles.get(pathname);
if (file && ["GET", "HEAD"].includes(request.method || "")) {
await serveStatic(request, response, file);
return;
}
if (!["GET", "HEAD", "POST"].includes(request.method || "")) {
sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, HEAD, POST"});
return;
}
sendJson(response, 404, {error: "Not found"});
} catch (error) {
console.error("Portal request failed", error.message);
sendJson(response, error.statusCode || 500, {error: error.message || "Request failed"});
}
});
server.listen(port, "0.0.0.0", () => {
console.log(`xiteng.site listening on :${port}; administrator=${adminUsername}`);
});
+1798
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
const cacheName = "xiteng-portal-v8";
const appShell = [
"/",
"/styles.css",
"/app.js?v=7",
"/manifest.webmanifest",
"/favicon.svg",
"/favicon.ico",
"/icons/favicon-32.png",
"/icons/apple-touch-icon.png",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(cacheName)
.then((cache) => cache.addAll(appShell))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== cacheName).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin) {
return;
}
if (url.pathname.startsWith("/api/")) {
event.respondWith(
fetch(request).catch(() => new Response(
JSON.stringify({error: "Network unavailable"}),
{
status: 503,
headers: {
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8"
}
}
))
);
return;
}
if (url.pathname.startsWith("/admin")
|| url.pathname.startsWith("/account")
|| url.pathname === "/admin.js"
|| url.pathname === "/account.js"
|| url.pathname.startsWith("/oauth2/")
|| url.pathname.startsWith("/outpost.goauthentik.io/")) {
event.respondWith(fetch(request));
return;
}
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok && ["/", "/index.html"].includes(url.pathname)) {
const copy = response.clone();
caches.open(cacheName).then((cache) => cache.put("/", copy));
}
return response;
})
.catch(() => caches.match("/"))
);
return;
}
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(cacheName).then((cache) => cache.put(request, copy));
}
return response;
});
if (cached) {
network.catch(() => undefined);
return cached;
}
return network;
})
);
});