From 0ce52dd085aa94de8402ed8d2876db084a90d2aa Mon Sep 17 00:00:00 2001 From: Kang Date: Sun, 4 Oct 2026 08:36:11 +0800 Subject: [PATCH] feat(docker): add docker-compose configuration for nyaterm service and update documentation references --- compose.web.yml => docker-compose.yml | 0 docs/web-architecture.md | 4 +- docs/web-deployment.md | 24 +++++----- src/components/panel/NetworkPanel.tsx | 3 +- src/components/panel/QuickCommands.tsx | 3 +- src/components/panel/ai/AIAssistantPanel.tsx | 5 ++- src/components/settings/AiTab.tsx | 7 +-- src/context/AppProvider.tsx | 3 +- src/context/ChildAppProvider.tsx | 3 +- src/lib/terminalZmodemUpload.ts | 5 ++- src/lib/transferDuplicateResolution.ts | 3 +- src/lib/uuid.test.ts | 47 ++++++++++++++++++++ src/lib/uuid.ts | 12 +++++ src/lib/workspaceTabs.ts | 3 +- src/pages/QuickCommandPage.tsx | 3 +- src/pages/TunnelPage.tsx | 3 +- 16 files changed, 100 insertions(+), 28 deletions(-) rename compose.web.yml => docker-compose.yml (100%) create mode 100644 src/lib/uuid.test.ts create mode 100644 src/lib/uuid.ts diff --git a/compose.web.yml b/docker-compose.yml similarity index 100% rename from compose.web.yml rename to docker-compose.yml diff --git a/docs/web-architecture.md b/docs/web-architecture.md index 4a1946a4..3593d9ed 100644 --- a/docs/web-architecture.md +++ b/docs/web-architecture.md @@ -42,7 +42,7 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co | `src-tauri/crates/nyaterm-web/src/{main,lib,auth,state,session,commands,sftp,ai,plugins,error}.rs` | 独立服务、显式命令 allowlist、会话和资源清理。 | | `src-tauri/crates/nyaterm-web/tests/{transport,bootstrap}.rs` | 真正 russh/SFTP fixture 与进程启动测试。 | | 各 crate Cargo.toml/Cargo.lock、`package.json`、`vite.config.ts` | 依赖、构建入口和 base path。 | -| `Dockerfile`、`.dockerignore`、`compose.web.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 | +| `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`.gitignore` | 三阶段非 root 容器、持久 volume、构建上下文/秘密排除。 | | `docs/web-deployment.md`、本文 | 操作步骤、能力、安全边界和验证报告。 | 原生 imports 只在 transport/platform 边界及被 capability 隔离的 RDP/VNC Channel、updater 和 native PluginPanel 资源转换中保留。不会在 Web startup 创建原生 Channel 或加载原生插件。 @@ -90,7 +90,7 @@ Core 和 Web 是两个独立 Cargo crate;Desktop 以 path dependency 引用 Co 这是单管理员实例,不是多租户 ACL 服务。共享保存配置和历史应只对可信管理员开放。高熵登录密码与独立 32 字节外部数据密钥必须配置;启动不能回退到 OS credential manager 或路径派生密钥。既有主密码层次保留,错误数据密钥启动失败。redb 的元数据/AI 历史不等于整个数据库加密。 -cookie 为随机 256 bit、HttpOnly、SameSite=Strict,HTTPS 下 Secure,Path 绑定 base path,8 小时有效。登录校验 Origin 与自定义请求头;mutation 校验 CSRF;WS 校验 cookie 和 Origin;所有静态/API/事件/传输入口校验配置的 Host 与存在的 Origin。没有 wildcard CORS,也不把 forwarded headers 当作认证依据。非回环 public URL 必须 HTTPS。登录尝试限额为全局每分钟 20 次。 +cookie 为随机 256 bit、HttpOnly、SameSite=Strict,HTTPS 下 Secure,Path 绑定 base path,8 小时有效。登录校验 Origin 与自定义请求头;mutation 校验 CSRF;WS 校验 cookie 和 Origin;所有静态/API/事件/传输入口校验配置的 Host 与存在的 Origin。没有 wildcard CORS,也不把 forwarded headers 当作认证依据。非回环 public URL 默认要求 HTTPS;可通过 `NYATERM_WEB_ALLOW_INSECURE_HTTP=true` 显式允许 HTTP,Host/Origin 校验仍保持不变。前端请求 ID 在没有 `crypto.randomUUID` 的 HTTP 环境使用 `crypto.getRandomValues` 生成 UUID v4。登录尝试限额为全局每分钟 20 次。 session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返回 404、prompt 返回 403。WS URL 不含 token。SSH unknown/changed key 均要求明确确认后才保存。secret 不进入日志/列表,响应使用 no-store,并设置 CSP/nosniff/referrer policy。Web command 显式 allowlist,不能反射调用任意 Tauri command、访问服务端任意本地路径或执行本地程序。浏览器插件框架不提供服务端文件/进程权限。 diff --git a/docs/web-deployment.md b/docs/web-deployment.md index bc605372..fef9219e 100644 --- a/docs/web-deployment.md +++ b/docs/web-deployment.md @@ -33,15 +33,17 @@ pnpm web:serve ## 环境变量 -| 变量 | 含义 | -| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `NYATERM_WEB_PASSWORD` / `NYATERM_WEB_PASSWORD_FILE` | 登录密码,至少 32 字符;推荐随机生成。指定 `_FILE` 时优先读取文件。 | -| `NYATERM_WEB_ENCRYPTION_KEY` / `NYATERM_WEB_ENCRYPTION_KEY_FILE` | 标准 Base64 编码的 32 字节随机密钥,必须持久保存,禁止重新生成后直接复用旧数据。 | -| `NYATERM_WEB_PUBLIC_URL` | 浏览器实际访问地址,含端口和 base path;默认 `http://localhost:8080/`。非回环地址必须为 HTTPS。 | -| `NYATERM_WEB_BIND` | 服务监听地址,默认 `127.0.0.1:8080`,容器为 `0.0.0.0:8080`。 | -| `NYATERM_WEB_DATA_DIR` | redb 数据目录,默认 `./nyaterm-web-data`,容器为 `/data`。 | -| `NYATERM_WEB_DIST` | 已构建 React 资源目录,默认 `dist`,容器为 `/app/dist`。 | -| `NYATERM_WEB_BASE_PATH` | **前端构建时**使用的路径,默认 `/`,必须与 PUBLIC_URL 的路径一致并以 `/` 结尾。 | +| 变量 | 含义 | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `NYATERM_WEB_PASSWORD` / `NYATERM_WEB_PASSWORD_FILE` | 登录密码,至少 32 字符;推荐随机生成。指定 `_FILE` 时优先读取文件。 | +| `NYATERM_WEB_ENCRYPTION_KEY` / `NYATERM_WEB_ENCRYPTION_KEY_FILE` | 标准 Base64 编码的 32 字节随机密钥,必须持久保存,禁止重新生成后直接复用旧数据。 | +| `NYATERM_WEB_PUBLIC_URL` | 浏览器实际访问地址,含端口和 base path;默认 `http://localhost:8080/`。非回环地址默认必须为 HTTPS。 | +| `NYATERM_WEB_BIND` | 服务监听地址,默认 `127.0.0.1:8080`,容器为 `0.0.0.0:8080`。 | +| `NYATERM_WEB_DATA_DIR` | redb 数据目录,默认 `./nyaterm-web-data`,容器为 `/data`。 | +| `NYATERM_WEB_DIST` | 已构建 React 资源目录,默认 `dist`,容器为 `/app/dist`。 | +| `NYATERM_WEB_BASE_PATH` | **前端构建时**使用的路径,默认 `/`,必须与 PUBLIC_URL 的路径一致并以 `/` 结尾。 | + +`NYATERM_WEB_ALLOW_INSECURE_HTTP=true` 可显式允许局域网 HTTP 地址;同时将 `NYATERM_WEB_PUBLIC_URL` 设置为浏览器实际使用的 IP/域名和端口,并将监听地址设为 `0.0.0.0:8080`。前端请求 ID 使用 `crypto.getRandomValues` 兼容 HTTP,但剪贴板等浏览器能力仍可能要求 HTTPS。Host/Origin 校验继续生效,访问地址必须与 PUBLIC_URL 一致。 服务启动会验证数据密钥;错误密钥会阻止启动。文件方式会去掉末尾换行。登录密码可独立替换,服务器重启使所有旧登录失效;加密密钥目前没有在线轮换 API。 @@ -64,10 +66,10 @@ docker run -d --name nyaterm-web --init \ 容器使用 UID / GID `10001:10001`。绑定目录时,确保该用户可以读取秘密文件和写入数据目录;新 named volume 会继承镜像中 `/data` 的所有权。默认端口映射仅暴露本机。如需远程访问,使用下面的 HTTPS 反向代理配置。 -仓库还提供 `compose.web.yml`,它通过外部环境变量注入密码和密钥: +仓库还提供 `docker-compose.yml`,它通过外部环境变量注入密码和密钥: ```sh -docker compose -f compose.web.yml up --build -d +docker compose -f docker-compose.yml up --build -d ``` 提前在当前 shell 或受限 `.env` 中设置两个必填秘密。`.env*` 已排除在 Git 和 Docker build context 外。生产环境优先将 Compose 改为 `secrets` 挂载和 `_FILE` 变量,避免把秘密写入镜像。Dockerfile 使用 Node、Rust、Debian 三阶段构建,最终镜像只含 dist、服务程序和运行依赖。 diff --git a/src/components/panel/NetworkPanel.tsx b/src/components/panel/NetworkPanel.tsx index b3783d97..14fe6890 100644 --- a/src/components/panel/NetworkPanel.tsx +++ b/src/components/panel/NetworkPanel.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { ChevronDownIcon, @@ -784,7 +785,7 @@ export default function NetworkPanel() { const groups = groupDialog.tab === "proxy" ? proxyGroups : tunnelGroups; const group = groupDialog.group ? { ...groupDialog.group, name } - : { id: crypto.randomUUID(), name, sort_order: groups.length }; + : { id: randomUUID(), name, sort_order: groups.length }; await invoke(groupDialog.tab === "proxy" ? "save_proxy_group" : "save_tunnel_group", { group, }); diff --git a/src/components/panel/QuickCommands.tsx b/src/components/panel/QuickCommands.tsx index c9832b99..41469aa8 100644 --- a/src/components/panel/QuickCommands.tsx +++ b/src/components/panel/QuickCommands.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { save as saveFileDialog } from "@/lib/backend/platform/dialog"; import { MoreHorizontalIcon } from "lucide-react"; @@ -441,7 +442,7 @@ function QuickCommands({ onSend, onSendToAll, sendDisabled = false }: QuickComma (name: string) => { if (!newCategoryDraft) return; const newCategory: QuickCommandCategory = { - id: crypto.randomUUID(), + id: randomUUID(), name, parent_id: newCategoryDraft.parentId || undefined, sort_order: getNextQuickCommandCategorySortOrder( diff --git a/src/components/panel/ai/AIAssistantPanel.tsx b/src/components/panel/ai/AIAssistantPanel.tsx index ff96f9a5..33979be5 100644 --- a/src/components/panel/ai/AIAssistantPanel.tsx +++ b/src/components/panel/ai/AIAssistantPanel.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { supports } from "@/lib/backend/runtime"; import { emit, listen, type UnlistenFn } from "@/lib/backend/api"; import { @@ -754,7 +755,7 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP setDetectedError(null); const assistantId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const requestStreamId = `ai-stream-${crypto.randomUUID()}`; + const requestStreamId = `ai-stream-${randomUUID()}`; let resolvedSessionId = requestSessionId ?? `pending-${requestStreamId}`; const userMessage = createLocalMessage("user", userInput, resolvedSessionId); const assistantMessage: AIMessage = { @@ -1118,7 +1119,7 @@ function AIAssistantPanel({ activePane, activeConnection, intent }: AIAssistantP }; const categoryId = existingCategory?.id ?? newCategory?.id; const command: QuickCommand = { - id: `ai-${crypto.randomUUID()}`, + id: `ai-${randomUUID()}`, label: card.title, command: card.command, category_id: categoryId, diff --git a/src/components/settings/AiTab.tsx b/src/components/settings/AiTab.tsx index ad77b6fc..da47e684 100644 --- a/src/components/settings/AiTab.tsx +++ b/src/components/settings/AiTab.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { open as openDialog } from "@/lib/backend/platform/dialog"; import { openUrl } from "@/lib/backend/platform/opener"; @@ -96,7 +97,7 @@ function updateDefaultModelId(ai: AISettings, models: AIModelConfigItem[]) { function newCredential(): AIProviderCredential { return { - id: `credential-${crypto.randomUUID()}`, + id: `credential-${randomUUID()}`, name: "", provider_kind: "openai_compatible", api_protocol: null, @@ -168,7 +169,7 @@ function providerProtocolSelectValue(credential: AIProviderCredential): string { function newAction(prefix: string): AICustomActionConfig { return { - id: `${prefix}-${crypto.randomUUID()}`, + id: `${prefix}-${randomUUID()}`, name: "自定义 AI 功能", prompt: "", enabled: true, @@ -1773,7 +1774,7 @@ export function AiModelsTab() { } else { credential = { ...newCredential(), - id: providerInfo && !existingBuiltin ? providerKind : `credential-${crypto.randomUUID()}`, + id: providerInfo && !existingBuiltin ? providerKind : `credential-${randomUUID()}`, name: availableProviderName(providerLabel, enabledCredentials), provider_kind: providerKind, base_url: providerInfo ? providerInfo.defaultBaseUrl : "", diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx index 2b91077c..71bb6a20 100644 --- a/src/context/AppProvider.tsx +++ b/src/context/AppProvider.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { type ReactNode, @@ -74,7 +75,7 @@ import { } from "./AppContext"; function createSessionRequestId() { - return crypto.randomUUID(); + return randomUUID(); } const DEFAULT_APP_SETTINGS: AppSettings = { diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index 3c9f6dce..8691a722 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { listen } from "@/lib/backend/api"; import { getCurrentWindow } from "@/lib/backend/platform/window"; import { @@ -388,7 +389,7 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { const noop = useCallback(() => {}, []); const noopString = useCallback(() => "", []); const noopPendingTab = useCallback( - () => ({ tabId: "", paneId: "", createRequestId: crypto.randomUUID() }), + () => ({ tabId: "", paneId: "", createRequestId: randomUUID() }), [], ); const noopPaneConnecting = useCallback(() => null, []); diff --git a/src/lib/terminalZmodemUpload.ts b/src/lib/terminalZmodemUpload.ts index a03dfd36..3e4a189b 100644 --- a/src/lib/terminalZmodemUpload.ts +++ b/src/lib/terminalZmodemUpload.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { toast } from "sonner"; import { getLocalPathName } from "@/components/panel/file-explorer/model"; import i18n from "@/i18n"; @@ -245,7 +246,7 @@ export async function probeAndResolveRemoteConflicts( const remotePath = remoteDir === "/" ? `/${name}` : `${remoteDir}/${name}`; const choice = await showTransferDuplicatePrompt({ - requestId: crypto.randomUUID(), + requestId: randomUUID(), sessionId, remotePath, fileName: name, @@ -301,7 +302,7 @@ async function resolveUnverifiedZmodemUpload( ? getLocalPathName(filePaths[0] ?? "", "file") : i18n.t("zmodem.multipleFiles", { count: filePaths.length }); const choice = await showTransferDuplicatePrompt({ - requestId: crypto.randomUUID(), + requestId: randomUUID(), sessionId, remotePath: "", fileName, diff --git a/src/lib/transferDuplicateResolution.ts b/src/lib/transferDuplicateResolution.ts index 2c0b8ea0..f0a7e521 100644 --- a/src/lib/transferDuplicateResolution.ts +++ b/src/lib/transferDuplicateResolution.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { getRemoteParentDirectory } from "@/components/panel/file-explorer/model"; import type { EnqueueUploadRequest } from "@/context/TransferContext"; import { invoke } from "@/lib/invoke"; @@ -67,7 +68,7 @@ async function resolveDuplicateChoice(params: { return "proceed"; case "ask": return showTransferDuplicatePrompt({ - requestId: crypto.randomUUID(), + requestId: randomUUID(), sessionId, remotePath, fileName, diff --git a/src/lib/uuid.test.ts b/src/lib/uuid.test.ts new file mode 100644 index 00000000..7da551f6 --- /dev/null +++ b/src/lib/uuid.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { randomUUID } from "./uuid"; +import { restoreTabFromPersistence } from "./workspaceTabs"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("request UUIDs", () => { + it("uses the native API when available", () => { + const native = vi.fn(() => "native-uuid"); + vi.stubGlobal("crypto", { randomUUID: native }); + expect(randomUUID()).toBe("native-uuid"); + expect(native).toHaveBeenCalledOnce(); + }); + + it("creates UUID v4 request IDs and restores SSH tabs without the secure-context API", () => { + const getRandomValues = globalThis.crypto.getRandomValues.bind( + globalThis.crypto, + ); + vi.stubGlobal("crypto", { getRandomValues }); + const ids = Array.from({ length: 100 }, () => randomUUID()); + expect(new Set(ids).size).toBe(ids.length); + for (const id of ids) { + expect(id).toMatch( + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ); + } + // Startup restoration used to throw before the terminal UI could mount. + const tab = restoreTabFromPersistence( + { + title: "SSH", + session_type: "SSH", + root: { + kind: "leaf", + id: "saved-pane", + session_type: "SSH", + title: "SSH", + connection_id: "saved-ssh", + }, + }, + 0, + ); + expect(tab?.root.kind).toBe("leaf"); + if (tab?.root.kind === "leaf") { + expect(tab.root.createRequestId).toMatch(/^[\da-f-]{36}$/); + } + }); +}); diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 00000000..60967876 --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,12 @@ +/** UUIDs for UI requests also work on explicitly enabled HTTP deployments. */ +export function randomUUID(): string { + if (typeof globalThis.crypto.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } + // getRandomValues is available outside secure contexts; randomUUID is not. + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; +} diff --git a/src/lib/workspaceTabs.ts b/src/lib/workspaceTabs.ts index 077a166e..e8a9fc3b 100644 --- a/src/lib/workspaceTabs.ts +++ b/src/lib/workspaceTabs.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import type { FileDocumentBackend, FileDocumentPane, @@ -632,7 +633,7 @@ function restorePane(node: RestorablePaneNode): PaneNode | null { } : undefined, connecting: true, - createRequestId: crypto.randomUUID(), + createRequestId: randomUUID(), } as SessionPane; } diff --git a/src/pages/QuickCommandPage.tsx b/src/pages/QuickCommandPage.tsx index 314e9917..1e3d3661 100644 --- a/src/pages/QuickCommandPage.tsx +++ b/src/pages/QuickCommandPage.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { emit } from "@/lib/backend/api"; import { getCurrentWindow } from "@/lib/backend/platform/window"; import { useEffect, useMemo, useState } from "react"; @@ -198,7 +199,7 @@ export default function QuickCommandPage() { let finalCategoryId = categoryId === "none" ? undefined : categoryId; let newCategory: QuickCommandCategory | undefined; if (categoryId === "new" && newCategoryName.trim()) { - const newId = crypto.randomUUID(); + const newId = randomUUID(); newCategory = { id: newId, name: newCategoryName.trim(), diff --git a/src/pages/TunnelPage.tsx b/src/pages/TunnelPage.tsx index 87e78d5f..0100cb9b 100644 --- a/src/pages/TunnelPage.tsx +++ b/src/pages/TunnelPage.tsx @@ -1,3 +1,4 @@ +import { randomUUID } from "@/lib/uuid"; import { emit } from "@/lib/backend/api"; import { getCurrentWindow } from "@/lib/backend/platform/window"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -111,7 +112,7 @@ export default function TunnelPage() { setSaveError(""); try { - const payload = nextTunnel.id ? nextTunnel : { ...nextTunnel, id: crypto.randomUUID() }; + const payload = nextTunnel.id ? nextTunnel : { ...nextTunnel, id: randomUUID() }; await invoke("save_tunnel", { tunnel: payload }); await emit("tunnel-saved"); getCurrentWindow().close();