feat(docker): add docker-compose configuration for nyaterm service and update documentation references

This commit is contained in:
Kang committed 2026-10-04 08:36:11 +08:00
1 parent 60c15946de
commit 0ce52dd085
16 files changed
+100 -28

No files matched your search

File renamed without changes.
+2 -2
View File
@@ -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、访问服务端任意本地路径或执行本地程序。浏览器插件框架不提供服务端文件/进程权限。
+13 -11
View File
@@ -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、服务程序和运行依赖。
+2 -1
View File
@@ -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,
});
+2 -1
View File
@@ -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(
+3 -2
View File
@@ -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,
+4 -3
View File
@@ -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 : "",
+2 -1
View File
@@ -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 = {
+2 -1
View File
@@ -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, []);
+3 -2
View File
@@ -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,
+2 -1
View File
@@ -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,
+47
View File
@@ -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}$/);
}
});
});
+12
View File
@@ -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("")}`;
}
+2 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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(),
+2 -1
View File
@@ -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();