feat(docker): add docker-compose configuration for nyaterm service and update documentation references
This commit is contained in:
1 parent
60c15946de
commit
0ce52dd085
16 files changed
+100
-28
No files matched your search
File renamed without changes.
@@ -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
@@ -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、服务程序和运行依赖。
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : "",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("")}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user