feat(web): enhance web functionality with backup and import/export features, improve logging configuration, and add end-to-end tests for UI components
This commit is contained in:
1 parent
cf84f0764a
commit
9f68379fb8
93 files changed
+5706
-1020
No files matched your search
@@ -48,4 +48,5 @@ web-test-data/
|
||||
|
||||
# Isolated deployment test traces and container logs
|
||||
artifacts/web-e2e/
|
||||
artifacts/web-local-e2e/
|
||||
playwright-report/
|
||||
@@ -13,6 +13,11 @@ services:
|
||||
NYATERM_WEB_ENCRYPTION_KEY: ${NYATERM_WEB_ENCRYPTION_KEY:?Set a persistent base64-encoded 32-byte encryption key}
|
||||
volumes:
|
||||
- nyaterm-data:/data
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
read_only: true
|
||||
|
||||
+23
-22
@@ -4,8 +4,8 @@
|
||||
"scope": "Production frontend invoke calls, conditional literal branches, browser adapter and explicit server allowlists. Static evidence only; capability-gated desktop calls are not Web defects.",
|
||||
"counts": {
|
||||
"browser-adapter": 23,
|
||||
"server-allowlist": 124,
|
||||
"desktop-or-unmatched": 147
|
||||
"server-allowlist": 125,
|
||||
"desktop-or-unmatched": 146
|
||||
},
|
||||
"serverCommands": [
|
||||
"append_ai_audit",
|
||||
@@ -93,6 +93,7 @@
|
||||
"get_system_fonts",
|
||||
"get_terminal_cwd",
|
||||
"import_connection_icon",
|
||||
"import_keyword_highlight_rules",
|
||||
"import_quick_commands",
|
||||
"increment_quick_command_use_count",
|
||||
"list_ai_model_names",
|
||||
@@ -217,7 +218,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/lib/logger.ts",
|
||||
"line": 300
|
||||
"line": 331
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -931,7 +932,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/panel/security-auth/KeyManagementTab.tsx",
|
||||
"line": 207
|
||||
"line": 203
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1103,7 +1104,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/hooks/useConfigTransfer.tsx",
|
||||
"line": 46
|
||||
"line": 78
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1113,7 +1114,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/hooks/useConfigTransfer.tsx",
|
||||
"line": 106
|
||||
"line": 168
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1255,7 +1256,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx",
|
||||
"line": 59
|
||||
"line": 66
|
||||
},
|
||||
{
|
||||
"file": "src/context/AppProvider.tsx",
|
||||
@@ -1863,7 +1864,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/panel/security-auth/KeyManagementTab.tsx",
|
||||
"line": 132
|
||||
"line": 133
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1873,7 +1874,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/panel/security-auth/KeyManagementTab.tsx",
|
||||
"line": 261
|
||||
"line": 257
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1883,7 +1884,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/panel/security-auth/KeyManagementTab.tsx",
|
||||
"line": 274
|
||||
"line": 270
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1897,7 +1898,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/components/panel/security-auth/KeyManagementTab.tsx",
|
||||
"line": 74
|
||||
"line": 76
|
||||
},
|
||||
{
|
||||
"file": "src/components/sessions/SshForm.tsx",
|
||||
@@ -2013,7 +2014,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/hooks/useConfigTransfer.tsx",
|
||||
"line": 70
|
||||
"line": 123
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2029,11 +2030,11 @@
|
||||
},
|
||||
{
|
||||
"command": "import_keyword_highlight_rules",
|
||||
"boundary": "desktop-or-unmatched",
|
||||
"boundary": "server-allowlist",
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/dialog/terminal/KeywordHighlightImportDialog.tsx",
|
||||
"line": 56
|
||||
"line": 63
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2063,7 +2064,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/dialog/connections/ImportDialog.tsx",
|
||||
"line": 167
|
||||
"line": 171
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2073,7 +2074,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/dialog/connections/ImportDialog.tsx",
|
||||
"line": 179
|
||||
"line": 213
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2083,11 +2084,11 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/dialog/connections/ImportDialog.tsx",
|
||||
"line": 166
|
||||
"line": 170
|
||||
},
|
||||
{
|
||||
"file": "src/components/dialog/connections/ImportDialog.tsx",
|
||||
"line": 200
|
||||
"line": 234
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2433,7 +2434,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/hooks/useConfigTransfer.tsx",
|
||||
"line": 85
|
||||
"line": 140
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2687,7 +2688,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/dialog/theme/ThemeDesignerDialog.tsx",
|
||||
"line": 229
|
||||
"line": 236
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3221,7 +3222,7 @@
|
||||
"locations": [
|
||||
{
|
||||
"file": "src/components/panel/security-auth/KeyManagementTab.tsx",
|
||||
"line": 186
|
||||
"line": 182
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3958,7 +3959,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/lib/backend/api.ts",
|
||||
"line": 13,
|
||||
"line": 14,
|
||||
"expression": "command"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -130,3 +130,11 @@ session/prompt/AI cancellation 隔离于登录 owner,跨 owner 的 session 返
|
||||
浏览器 SSH/Telnet 由 `BrowserTerminals` 持有每会话连接状态,初次握手和掉线恢复统一执行有时间预算的附着重试,复用 `get_session_info` 查询和原有终端事件,无新增重连协议。跨 WS/SSE 的关闭事件只投递一次。
|
||||
|
||||
Web 上传使用 SFTP lstat 区分存在、目录、权限和连接错误,复用 owner 冲突提示。临时文件和传输许可由 RAII guard 持有直到异步清理结束,显式会话关闭等待清理后才断开 SSH;提示 future 被 HTTP 中断丢弃时仍会移除待响应记录。数据格式保持兼容,新增响应字段见部署文档。
|
||||
|
||||
## 密码备份与诊断实现
|
||||
|
||||
可移植快照的类型、编解码、旧版恢复和备份加密现位于 Core;桌面保留原导入导出入口。Web 使用共享格式、显式源/目标密钥转换及单个 redb 恢复事务,不切换全局主密码、不写入源部署密钥。连接解析也迁入 Core,Termius 仅通过桌面 feature 启用;Web WindTerm 解析器明确禁止本机资源读取。
|
||||
|
||||
Web 的 `backups.rs` 提供有大小限制的上传/下载 API,`observability.rs` 提供 tracing、请求关联、动态设置、有界后台写入、轮转、上报二次脱敏及诊断 ZIP。浏览器文件传输位于 `configTransfer.ts` / `browserArtifacts.ts`;日志认证传输独立于 invoke/logger,避免递归。SSE/WebSocket 使用保存请求 span 的断连守卫,后台连接和阻塞文件任务也继承 span。
|
||||
|
||||
`tests/backups.rs` 使用真实根路径与子路径 Web 进程,覆盖桌面→Web、不同部署密钥 Web→Web、桌面编解码读取 Web 备份、真实凭据解密、密码/损坏/事务失败回滚、诊断脱敏与请求 ID、重启后解密。Core 保留旧版解码及各来源解析测试;Web 日志测试覆盖轮转、保留上限、文件写入失败和仍打开文件的诊断读取。前端测试覆盖登录状态与重连、取消文件选择、重复提交、备份密码确认、有界认证日志队列和断网退避。实际执行结果以本次交付记录为准;容器/OpenSSH 场景仍需运行 Docker 验收。
|
||||
+51
-2
@@ -121,7 +121,7 @@ Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/
|
||||
|
||||
刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。
|
||||
|
||||
测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像实构建及浏览器手工验收尚未执行,仍需在部署环境完成。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。
|
||||
测试与架构见 [web-architecture.md](web-architecture.md)。当前环境未安装 Docker CLI;镜像构建、容器内日志和 OpenSSH 部署验收仍需在具备 Docker 的环境执行。更新后请重新构建镜像并重建容器,旧镜像不会自动获得协议支持。
|
||||
|
||||
## Web Beta 能力矩阵与验收
|
||||
|
||||
@@ -141,4 +141,53 @@ Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/
|
||||
|
||||
SSH/Telnet 初次附着及断线恢复的每轮预算为 25 秒,单次握手最多 5 秒;退避依次 1、2、4 秒,之后不超过 5 秒。握手失败会查询当前会话;401/404 停止恢复,其余临时失败继续到预算结束。显式关闭、注销、服务端关闭均取消恢复。注销从 File 菜单进入。
|
||||
|
||||
本轮检查结果、只读 CI 和真实 Docker/OpenSSH/Playwright 验收命令见 [Web Beta 合并准备](web-merge-readiness.md)。本机尚未执行实际 Docker/Chromium,不能以接口 fixture 测试代替部署验收。
|
||||
本轮检查结果、只读 CI 和真实 Docker/OpenSSH/Playwright 验收命令见 [Web Beta 合并准备](web-merge-readiness.md)。Docker/OpenSSH 验收需要 Docker CLI;本机还可运行下述独立 Chromium 验收,但它不代替容器和反向代理验收。
|
||||
|
||||
## 密码备份与文件导入
|
||||
|
||||
File 菜单的导出在浏览器中设置并确认本次备份密码,下载密码保护的 `.nya` 文件;导入选择文件、输入该文件的原备份密码,并确认覆盖实例配置。取消选择不会报错,执行期间不能重复提交。备份密码与 Web 登录密码、部署加密密钥相互独立,仅在本次请求中使用。
|
||||
|
||||
`.nya` 延续桌面已有格式和旧版解码能力。Web 导出将凭据包装到独立的备份密钥中;Web 导入解开源密钥,把凭据重新加密到目标实例。可以在使用不同部署密钥的 Web 实例之间迁移,不需要复制或替换目标 `NYATERM_WEB_ENCRYPTION_KEY`。桌面导出的备份密码是导出时的桌面主密码;当前桌面导入入口也使用桌面主密码解密文件,因此导入 Web 备份前需将桌面主密码设为该备份密码。
|
||||
|
||||
导入先校验密码、完整性、数据关系和凭据,再用单个 redb 事务恢复;失败保留原配置。备份中的 Serial、本地 Shell 等桌面配置会保留,Web 能力检查控制使用。导入成功后刷新连接、设置、快捷命令、历史和笔记。运行中的会话仍使用已建立的连接。
|
||||
|
||||
连接导入支持 Xshell、MobaXterm、SecureCRT、WindTerm、Electerm、NyaTerm JSON 的单文件导出。服务端只解析上传内容,不读取上传内容指定的服务器路径。WindTerm 如果依赖外部配置或私钥,需先在桌面完整导入再迁移 `.nya`;FinalShell 目录、Termius 本机数据库和自动扫描 `~/.ssh/config` 仅桌面支持。Xshell、MobaXterm、SecureCRT 的导出通常不包含密码,导入后会提示补充凭据。主题、快捷命令、关键词规则和私钥的可见文件入口使用浏览器选择与下载。
|
||||
|
||||
| API | 正文与结果 | 上限 |
|
||||
| ------------------------------ | --------------------------------------------------------- | ---------------------------- |
|
||||
| `POST api/backups/export` | JSON `{ "password": "…" }`;返回 `.nya` 二进制 | 输出 50 MiB |
|
||||
| `POST api/backups/import` | multipart `file`、`password`;成功返回 `status=completed` | 文件 50 MiB,解压 50 MiB |
|
||||
| `POST api/imports/connections` | multipart `file`、`source`;返回 `imported`、`warnings` | 文件 10 MiB,归档展开 50 MiB |
|
||||
|
||||
上述接口要求当前登录、正确的 Origin 和 CSRF;`source` 只接受 `xshell`、`mobaxterm`、`securecrt`、`windterm`、`electerm`、`nyaterm_json`,不接受服务器文件路径。
|
||||
|
||||
## 部署日志与诊断
|
||||
|
||||
服务默认输出 JSON 日志到 stdout,可用 `docker logs --tail 200 nyaterm-web` 或 `docker compose logs --tail 200 nyaterm` 查看。同时写入数据目录的 `logs/` 子目录。日志按 UTC 日期或 10 MiB 轮转,总量不超过 100 MiB;诊断设置中的级别和保留天数保存后立即生效,保留天数为 1–30 天。文件写入失败时继续 stdout 日志并保持业务运行,正常停机刷新日志。
|
||||
|
||||
Compose 使用 Docker `json-file` 日志驱动,单文件 10 MiB、最多 3 个。单独 `docker run` 时也可添加 `--log-opt max-size=10m --log-opt max-file=3`。Docker stdout 日志与应用数据目录日志分别轮转。
|
||||
|
||||
每个 API 响应包含 `X-Nyaterm-Request-Id`;JSON 错误也带 `request_id`。前端 invoke 的请求 ID 传入服务端,日志记录路由模板、操作、状态码、耗时和相关会话 ID。异步连接任务延续请求关联。底层错误先记录类别、错误码、固定原因及原始错误指纹,再返回通用错误。终端帧和命令正文不会逐条记录。
|
||||
|
||||
浏览器未登录时仅保留有界内存队列,登录后批量上传现有 logger、未捕获异常和 Promise rejection。每批最多 50 条、256 KiB,服务端每个登录每分钟最多接受 120 批;断网上报最多重试两次(1 秒、2 秒退避),丢弃数量合并到后续批次,不递归记录上报失败。`POST api/logs/frontend` 和 `GET api/diagnostics/export` 要求登录;POST 同时校验 Origin 与 CSRF。
|
||||
|
||||
在 Web 设置的诊断区下载 ZIP 包,包含最多 20 MiB 的近期脱敏日志,以及版本、系统、架构和能力清单。任意错误文本和不可信文本以指纹记录;密码、Cookie、CSRF、私钥、命令正文、终端内容不会写入诊断包,数据库和备份也不会收录。Web 隐藏打开本地日志目录入口。本次不提供页面日志查看器。
|
||||
|
||||
## 本机浏览器验收
|
||||
|
||||
安装 Chromium 后,以本机编译的 Web 服务和真实 dist 执行登录、主题/窄屏截图、备份往返、诊断下载及会话恢复:
|
||||
|
||||
```sh
|
||||
pnpm exec playwright install chromium
|
||||
pnpm build:web
|
||||
cargo build --manifest-path src-tauri/crates/nyaterm-web/Cargo.toml --locked
|
||||
node scripts/web-local-e2e.mjs
|
||||
```
|
||||
|
||||
子路径验收先用 `NYATERM_WEB_BASE_PATH=/nyaterm/` 重新构建前端,再设置 `NYATERM_LOCAL_E2E_BASE_PATH=/nyaterm/` 执行同一脚本。测试使用临时数据目录和随机秘密,退出后清理服务进程。截图保存在 `artifacts/web-local-e2e/`。
|
||||
|
||||
本轮本机验证(2026-10-04):Core 413 项测试通过;Web Rust 单元及集成测试通过;相关 Vitest、桌面 `cargo check --locked`、Web 根路径/子路径构建、lint、四语言键检查和能力审计通过。lint 保留 `CommandSuggestions.tsx` 原有的一项依赖提示,构建保留原有资源体积提示。
|
||||
|
||||
真实 Chromium 已分别通过根路径和 `/nyaterm/` 的登录错误、限流/断网/SSE 故障注入、会话恢复、密码备份往返、主题与快捷命令文件往返、诊断下载;已检查四语言、浅深主题及 360px 截图。迁移测试使用真实 Web 进程和独立部署密钥,验证密码、私钥和 OTP 解密、损坏/错误密码/超限拒绝、事务回滚及重启后读取,并检查日志脱敏、请求关联、上报频率和级别即时生效。
|
||||
|
||||
本机没有 Docker CLI,因此没有执行镜像构建、容器内轮转或 Docker/OpenSSH/反向代理验收;使用 `pnpm test:web:e2e` 在具备 Docker 的环境继续验证。桌面 `.nya` 兼容验证使用共享编解码和原桌面快照流程,未执行桌面 UI 导入验收。
|
||||
@@ -2,7 +2,9 @@ import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "deployment.spec.mjs",
|
||||
testMatch: process.env.NYATERM_E2E_FEATURES_ONLY
|
||||
? "web-features.spec.mjs"
|
||||
: ["deployment.spec.mjs", "web-features.spec.mjs"],
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
workers: 1,
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const locale = (language) =>
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
new URL(`../src/i18n/locales/${language}.json`, import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const en = locale("en");
|
||||
const t = (key, dictionary = en) =>
|
||||
key.split(".").reduce((value, part) => value[part], dictionary);
|
||||
|
||||
test("login layouts in four languages, light/dark themes and narrow screens", async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
for (const language of ["en", "zh-CN", "zh-TW", "ko"]) {
|
||||
for (const theme of ["github-light", "github-dark"]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 360, height: 760 },
|
||||
locale: language,
|
||||
});
|
||||
await context.addInitScript(
|
||||
({ language, theme }) => {
|
||||
localStorage.setItem("nyaterm-web-language", language);
|
||||
localStorage.setItem("df-theme-id", theme);
|
||||
},
|
||||
{ language, theme },
|
||||
);
|
||||
const page = await context.newPage();
|
||||
await page.goto(process.env.NYATERM_E2E_URL);
|
||||
const password = page.getByLabel(t("web.password", locale(language)), {
|
||||
exact: true,
|
||||
});
|
||||
await expect(password).toBeEnabled();
|
||||
await expect(password).toBeFocused();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: t("web.welcome", locale(language)) }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(
|
||||
page.locator('img[src$="icons/app/nyaterm.svg"]'),
|
||||
).toBeVisible();
|
||||
const image = await page.screenshot({
|
||||
path: testInfo.outputPath(`login-${language}-${theme}-360px.png`),
|
||||
});
|
||||
await testInfo.attach(`login-${language}-${theme}-360px`, {
|
||||
body: image,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("password errors, keyboard login, SSE retry, portable backup and diagnostic downloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
if (!process.env.NYATERM_E2E_PASSWORD)
|
||||
throw new Error("Run scripts/web-local-e2e.mjs or pnpm test:web:e2e");
|
||||
const base = process.env.NYATERM_E2E_URL;
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
await page.goto(base);
|
||||
const password = page.getByLabel(t("web.password"), { exact: true });
|
||||
await expect(password).toBeEnabled();
|
||||
await password.fill("incorrect-password");
|
||||
await password.press("Enter");
|
||||
await expect(page.getByRole("alert")).toHaveText(t("web.invalidPassword"));
|
||||
await page.getByRole("button", { name: t("web.showPassword") }).click();
|
||||
await expect(password).toHaveAttribute("type", "text");
|
||||
await page.getByRole("button", { name: t("web.hidePassword") }).click();
|
||||
|
||||
// Faults are injected only at the browser transport; login and restore use the real server.
|
||||
await page.route("**/api/auth/login", (route) =>
|
||||
route.fulfill({
|
||||
status: 429,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Limited" }),
|
||||
}),
|
||||
);
|
||||
await password.press("Enter");
|
||||
await expect(page.getByRole("alert")).toHaveText(t("web.signInRateLimited"));
|
||||
await page.unroute("**/api/auth/login");
|
||||
await page.route("**/api/auth/login", (route) =>
|
||||
route.abort("internetdisconnected"),
|
||||
);
|
||||
await password.press("Enter");
|
||||
await expect(page.getByRole("alert")).toHaveText(t("web.serverUnavailable"));
|
||||
await page.unroute("**/api/auth/login");
|
||||
await page.route("**/api/events", (route) =>
|
||||
route.abort("internetdisconnected"),
|
||||
);
|
||||
let loginCount = 0;
|
||||
page.on("request", (request) => {
|
||||
if (request.url().endsWith("/api/auth/login")) loginCount++;
|
||||
});
|
||||
await password.fill(process.env.NYATERM_E2E_PASSWORD);
|
||||
await password.press("Enter");
|
||||
await expect(page.getByRole("alert")).toHaveText(
|
||||
t("web.eventConnectionFailed"),
|
||||
);
|
||||
await page.unroute("**/api/events");
|
||||
await page.getByRole("button", { name: t("web.retryConnection") }).click();
|
||||
await expect(password).toHaveCount(0);
|
||||
expect(loginCount).toBe(1);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(t("menu.file"), { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
expect(loginCount).toBe(1);
|
||||
|
||||
const auth = await (
|
||||
await page.request.get(new URL("api/auth/session", base).href)
|
||||
).json();
|
||||
const headers = {
|
||||
Origin: new URL(base).origin,
|
||||
"X-Nyaterm-Request": "1",
|
||||
"X-Nyaterm-Csrf": auth.csrf,
|
||||
};
|
||||
const command = async (name, args = {}) => {
|
||||
const id = randomUUID();
|
||||
const response = await page.request.post(
|
||||
new URL(`api/commands/${name}`, base).href,
|
||||
{ headers: { ...headers, "X-Nyaterm-Request-Id": id }, data: args },
|
||||
);
|
||||
expect(response.headers()["x-nyaterm-request-id"]).toBe(id);
|
||||
expect(response.ok(), `${name}: ${await response.text()}`).toBe(true);
|
||||
return response.json();
|
||||
};
|
||||
await command("save_password", {
|
||||
entry: {
|
||||
id: "web-e2e-password",
|
||||
name: "Web backup fixture",
|
||||
password: "portable-secret",
|
||||
},
|
||||
});
|
||||
const menu = async (key) => {
|
||||
await page.getByText(t("menu.file"), { exact: true }).first().click();
|
||||
await page.getByText(t(key), { exact: true }).click();
|
||||
};
|
||||
await menu("settings.exportConfig");
|
||||
await page
|
||||
.getByLabel(t("web.backupPassword"), { exact: true })
|
||||
.fill("web-e2e-backup-password");
|
||||
await page
|
||||
.getByLabel(t("web.backupPasswordConfirm"), { exact: true })
|
||||
.fill("different");
|
||||
await expect(
|
||||
page.getByRole("button", { name: t("web.backupExportTitle"), exact: true }),
|
||||
).toBeDisabled();
|
||||
await page
|
||||
.getByLabel(t("web.backupPasswordConfirm"), { exact: true })
|
||||
.fill("web-e2e-backup-password");
|
||||
const download = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: t("web.backupExportTitle"), exact: true })
|
||||
.click();
|
||||
const backup = await download;
|
||||
expect(backup.suggestedFilename()).toMatch(/\.nya$/);
|
||||
const backupBytes = readFileSync(await backup.path());
|
||||
expect(backupBytes.includes(Buffer.from("portable-secret"))).toBe(false);
|
||||
await command("delete_password", { id: "web-e2e-password" });
|
||||
await menu("settings.importConfig");
|
||||
await page.getByRole("button", { name: /NyaTerm \(\.nya\)/ }).click();
|
||||
const chooser = page.waitForEvent("filechooser");
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: t("savedConnections.restoreBackupConfirmAction"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await (
|
||||
await chooser
|
||||
).setFiles({
|
||||
name: "fixture.nya",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: backupBytes,
|
||||
});
|
||||
await page
|
||||
.getByLabel(t("web.backupPassword"), { exact: true })
|
||||
.fill("wrong-password");
|
||||
await page
|
||||
.getByLabel(t("web.backupOverwriteConfirm"), { exact: true })
|
||||
.check();
|
||||
await page
|
||||
.getByRole("button", { name: t("web.backupImportTitle"), exact: true })
|
||||
.click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
t("web.backupImportFailed"),
|
||||
);
|
||||
await page
|
||||
.getByLabel(t("web.backupPassword"), { exact: true })
|
||||
.fill("web-e2e-backup-password");
|
||||
await page
|
||||
.getByRole("button", { name: t("web.backupImportTitle"), exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByLabel(t("web.backupPassword"), { exact: true }),
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
await command("get_saved_password_value", { id: "web-e2e-password" }),
|
||||
).toBe("portable-secret");
|
||||
|
||||
await command("save_quick_commands", {
|
||||
config: {
|
||||
commands: [
|
||||
{
|
||||
id: "web-quick-fixture",
|
||||
label: "Web quick fixture",
|
||||
command: "printf fixture",
|
||||
},
|
||||
],
|
||||
categories: [],
|
||||
},
|
||||
});
|
||||
const settings = await command("get_app_settings");
|
||||
settings.ui.active_right_panel = "quickCommands";
|
||||
settings.ui.right_open_panels = ["quickCommands"];
|
||||
await command("save_app_settings", { settings });
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText("Web quick fixture", { exact: true }),
|
||||
).toBeVisible();
|
||||
const quickDownload = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: t("quickCommands.export"), exact: true })
|
||||
.click();
|
||||
const quickBytes = readFileSync(await (await quickDownload).path());
|
||||
expect(
|
||||
JSON.parse(quickBytes.toString()).commands.some(
|
||||
(entry) => entry.id === "web-quick-fixture",
|
||||
),
|
||||
).toBe(true);
|
||||
await command("save_quick_commands", {
|
||||
config: { commands: [], categories: [] },
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: t("quickCommands.import"), exact: true })
|
||||
.click();
|
||||
const quickChooser = page.waitForEvent("filechooser");
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: new RegExp(t("quickCommands.importNyaTermJson")),
|
||||
})
|
||||
.click();
|
||||
await (
|
||||
await quickChooser
|
||||
).setFiles({
|
||||
name: "quick.json",
|
||||
mimeType: "application/json",
|
||||
buffer: quickBytes,
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await command("get_quick_commands")).commands.some(
|
||||
(entry) => entry.id === "web-quick-fixture",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: t("settings.title"), exact: true })
|
||||
.click();
|
||||
const child = page.frameLocator('iframe[src*="window=settings"]');
|
||||
await child
|
||||
.getByRole("button", { name: t("settings.general"), exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(
|
||||
child.getByRole("button", { name: t("settings.openLogs"), exact: true }),
|
||||
).toHaveCount(0);
|
||||
const diagnosticDownload = page.waitForEvent("download");
|
||||
await child
|
||||
.getByRole("button", { name: t("settings.exportDiagnostics"), exact: true })
|
||||
.click();
|
||||
expect(
|
||||
readFileSync(await (await diagnosticDownload).path())
|
||||
.subarray(0, 2)
|
||||
.toString(),
|
||||
).toBe("PK");
|
||||
await child
|
||||
.getByRole("button", { name: t("settings.appearance"), exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await child
|
||||
.getByRole("button", { name: t("settings.themeDesignerOpen"), exact: true })
|
||||
.click();
|
||||
await child
|
||||
.getByRole("button", {
|
||||
name: t("settings.themeDesignerCopyTheme"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const themeDownload = page.waitForEvent("download");
|
||||
await child
|
||||
.getByRole("button", {
|
||||
name: t("settings.themeDesignerExport"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const themeData = JSON.parse(
|
||||
readFileSync(await (await themeDownload).path()).toString(),
|
||||
);
|
||||
themeData.name = "Web theme fixture";
|
||||
const themeChooser = page.waitForEvent("filechooser");
|
||||
await child
|
||||
.getByRole("button", {
|
||||
name: t("settings.themeDesignerImport"),
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await (
|
||||
await themeChooser
|
||||
).setFiles({
|
||||
name: "theme.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(themeData)),
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await command("get_app_settings")).appearance.custom_themes.some(
|
||||
(theme) => theme.name === "Web theme fixture",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await child
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: t("common.close"), exact: true })
|
||||
.click();
|
||||
|
||||
await child
|
||||
.getByRole("button", { name: t("common.confirm"), exact: true })
|
||||
.click();
|
||||
await expect(page.locator('iframe[src*="window=settings"]')).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(t("menu.file"), { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createServer } from "node:net";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const listener = createServer();
|
||||
await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve));
|
||||
const port = listener.address().port;
|
||||
await new Promise((resolve) => listener.close(resolve));
|
||||
const data = mkdtempSync(join(tmpdir(), "nyaterm-local-e2e-"));
|
||||
const artifacts = resolve(
|
||||
"artifacts/web-local-e2e",
|
||||
process.env.NYATERM_LOCAL_E2E_BASE_PATH ? "subpath" : "root",
|
||||
);
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const basePath = process.env.NYATERM_LOCAL_E2E_BASE_PATH ?? "/";
|
||||
const base = `http://127.0.0.1:${port}${basePath}`;
|
||||
const password = randomBytes(32).toString("base64");
|
||||
const env = {
|
||||
...process.env,
|
||||
NYATERM_WEB_PASSWORD: password,
|
||||
NYATERM_WEB_ENCRYPTION_KEY: randomBytes(32).toString("base64"),
|
||||
NYATERM_WEB_DATA_DIR: data,
|
||||
NYATERM_WEB_DIST: resolve("dist"),
|
||||
NYATERM_WEB_PUBLIC_URL: base,
|
||||
NYATERM_WEB_BIND: `127.0.0.1:${port}`,
|
||||
};
|
||||
delete env.NYATERM_WEB_PASSWORD_FILE;
|
||||
delete env.NYATERM_WEB_ENCRYPTION_KEY_FILE;
|
||||
const exe = resolve(
|
||||
"src-tauri/crates/nyaterm-web/target/debug/nyaterm-web" +
|
||||
(process.platform === "win32" ? ".exe" : ""),
|
||||
);
|
||||
const server = spawn(exe, [], {
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "inherit"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let serverError;
|
||||
server.on("error", (error) => {
|
||||
serverError = error;
|
||||
});
|
||||
try {
|
||||
let ready = false;
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
if (serverError) throw serverError;
|
||||
if (server.exitCode !== null)
|
||||
throw new Error("Local Web server exited during startup");
|
||||
try {
|
||||
if ((await fetch(base)).ok) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await delay(50);
|
||||
}
|
||||
if (!ready) throw new Error("Local Web server was not ready");
|
||||
const test = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"node_modules/@playwright/test/cli.js",
|
||||
"test",
|
||||
"--config",
|
||||
"e2e/playwright.config.mjs",
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
NYATERM_E2E_FEATURES_ONLY: "1",
|
||||
NYATERM_E2E_URL: base,
|
||||
NYATERM_E2E_PASSWORD: password,
|
||||
NYATERM_E2E_ARTIFACTS: artifacts,
|
||||
},
|
||||
stdio: "inherit",
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
test.once("error", reject);
|
||||
test.once("exit", resolve);
|
||||
});
|
||||
if (code !== 0) process.exitCode = 1;
|
||||
} finally {
|
||||
if (server.exitCode === null && !serverError) {
|
||||
const exited = new Promise((resolve) => server.once("exit", resolve));
|
||||
server.kill();
|
||||
await exited;
|
||||
}
|
||||
rmSync(data, { recursive: true, force: true });
|
||||
}
|
||||
Generated
+8
@@ -5007,9 +5007,12 @@ dependencies = [
|
||||
name = "nyaterm-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"aes-gcm 0.10.3",
|
||||
"async-http-proxy",
|
||||
"base64 0.22.1",
|
||||
"cbc 0.1.2",
|
||||
"crypto_secretbox",
|
||||
"dirs",
|
||||
"encoding_rs",
|
||||
"font-kit",
|
||||
@@ -5017,19 +5020,24 @@ dependencies = [
|
||||
"genai",
|
||||
"hex",
|
||||
"hmac 0.12.1",
|
||||
"keyring",
|
||||
"md-5 0.10.6",
|
||||
"nucleo-matcher",
|
||||
"nyaterm-gpu",
|
||||
"pbkdf2 0.12.2",
|
||||
"quick-xml 0.40.1",
|
||||
"rand 0.8.8",
|
||||
"redb",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"russh",
|
||||
"russh-sftp",
|
||||
"rusty-leveldb",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.10.7",
|
||||
"sha2 0.10.9",
|
||||
"sha3 0.10.9",
|
||||
"ssh-key 0.6.7",
|
||||
"strip-ansi-escapes",
|
||||
"thiserror 2.0.20",
|
||||
|
||||
@@ -86,7 +86,7 @@ nyaterm-core = { path = "crates/nyaterm-core", features = ["test-support"] }
|
||||
tokio = { version = "1", features = ["test-util"] }
|
||||
|
||||
[dependencies]
|
||||
nyaterm-core = { path = "crates/nyaterm-core" }
|
||||
nyaterm-core = { path = "crates/nyaterm-core", features = ["desktop-importer"] }
|
||||
tempfile = "3"
|
||||
nyaterm-gpu = { path = "crates/nyaterm-gpu" }
|
||||
nyaterm-otp = { path = "crates/otp" }
|
||||
|
||||
Generated
+880
-29
File diff suppressed because it is too large.
Load diff
@@ -6,10 +6,11 @@ rust-version = "1.94"
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
desktop-importer = ["dep:keyring", "dep:crypto_secretbox", "dep:rusty-leveldb"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_json = { version = "1", features = ["raw_value"] }
|
||||
thiserror = "2"
|
||||
redb = "4.1.0"
|
||||
aes-gcm = "0.10"
|
||||
@@ -46,6 +47,16 @@ vnc-rs = { path = "../../vendor/vnc-rs" }
|
||||
tokio-socks = "0.5.2"
|
||||
async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"] }
|
||||
|
||||
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
pbkdf2 = "0.12"
|
||||
sha3 = "0.10"
|
||||
quick-xml = "0.40"
|
||||
crypto_secretbox = { version = "0.1", optional = true }
|
||||
rusty-leveldb = { version = "4.0", optional = true }
|
||||
keyring = { version = "3.6", optional = true, features = ["apple-native", "windows-native", "sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Password-portable backups. Runtime key material is never replaced during an operation.
|
||||
use super::{backup_crypto, portable_snapshot::*};
|
||||
use crate::{
|
||||
config,
|
||||
error::{AppError, AppResult},
|
||||
storage,
|
||||
utils::crypto,
|
||||
};
|
||||
use aes_gcm::{Aes256Gcm, Key, KeyInit, aead::OsRng};
|
||||
|
||||
pub const MAX_BACKUP_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
||||
pub fn build_backup(app_version: &str) -> AppResult<PortableSnapshot> {
|
||||
let mut settings = config::load_app_settings(&())?;
|
||||
// Loading can retain undecipherable settings for recovery; exports must reject them.
|
||||
settings.ai = config::decrypt_ai_settings(settings.ai)?;
|
||||
let mut snapshot = PortableSnapshot {
|
||||
schema_version: PORTABLE_SNAPSHOT_SCHEMA_VERSION,
|
||||
snapshot_kind: PortableSnapshotKind::Backup,
|
||||
revision_id: uuid::Uuid::new_v4().to_string(),
|
||||
device_id: config::load_cloud_sync_state(&())?.device_id,
|
||||
created_at_ms: current_time_ms(),
|
||||
payload_hash: String::new(),
|
||||
app_version: app_version.into(),
|
||||
settings: PortableAppSettings::from_app_settings(&settings, &PortableSnapshotKind::Backup),
|
||||
sessions: config::load_sessions(&())?,
|
||||
keys: config::load_keys(&())?,
|
||||
passwords: config::load_passwords(&())?,
|
||||
credentials: config::load_credentials(&())?,
|
||||
otp: config::load_otp_entries(&())?,
|
||||
proxies: config::load_proxies(&())?,
|
||||
proxy_groups: config::load_proxy_groups(&())?,
|
||||
tunnels: config::load_tunnels(&())?,
|
||||
tunnel_groups: config::load_tunnel_groups(&())?,
|
||||
quick_commands: config::load_quick_commands(&())?,
|
||||
history: storage::list_command_history_entries(usize::MAX)?,
|
||||
master_key_token: storage::load_master_key_token()?,
|
||||
known_hosts: storage::render_known_hosts_export()?,
|
||||
notes: storage::load_notes_snapshot()?,
|
||||
};
|
||||
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn transcode(
|
||||
snapshot: &mut PortableSnapshot,
|
||||
source: &Key<Aes256Gcm>,
|
||||
target: &Key<Aes256Gcm>,
|
||||
) -> AppResult<()> {
|
||||
let convert = |value: &mut Option<String>| -> AppResult<()> {
|
||||
if let Some(token) = value {
|
||||
*token = crypto::transcode_secret(token, source, target)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for connection in &mut snapshot.sessions.connections {
|
||||
if let Some(auth) = &mut connection.auth {
|
||||
convert(&mut auth.password)?;
|
||||
}
|
||||
}
|
||||
for key in &mut snapshot.keys.keys {
|
||||
convert(&mut key.key)?;
|
||||
convert(&mut key.cert)?;
|
||||
convert(&mut key.passphrase)?;
|
||||
}
|
||||
for account in &mut snapshot.passwords.passwords {
|
||||
convert(&mut account.password)?;
|
||||
}
|
||||
for credential in &mut snapshot.credentials.credentials {
|
||||
convert(&mut credential.password)?;
|
||||
}
|
||||
for otp in &mut snapshot.otp.entries {
|
||||
convert(&mut otp.secret)?;
|
||||
}
|
||||
for proxy in &mut snapshot.proxies {
|
||||
convert(&mut proxy.password)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn export_backup(password: &str, app_version: &str) -> AppResult<Vec<u8>> {
|
||||
validate_password(password)?;
|
||||
let mut snapshot = build_backup(app_version)?;
|
||||
let portable_key = Aes256Gcm::generate_key(OsRng);
|
||||
transcode(&mut snapshot, &crypto::get_master_key()?, &portable_key)?;
|
||||
snapshot.master_key_token = Some(crypto::backup_key_token(&portable_key, password)?);
|
||||
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
|
||||
let encoded = zeroize::Zeroizing::new(encode_portable_snapshot(&snapshot)?);
|
||||
let encrypted = backup_crypto::encrypt_snapshot_bytes(&encoded, password)?;
|
||||
if encrypted.len() > MAX_BACKUP_BYTES {
|
||||
return Err(AppError::Config("Backup exceeds 50 MiB".into()));
|
||||
}
|
||||
Ok(encrypted)
|
||||
}
|
||||
|
||||
/// Decode and re-encrypt everything before acquiring a database write transaction.
|
||||
pub fn prepare_import(bytes: &[u8], password: &str) -> AppResult<PortableSnapshot> {
|
||||
validate_password(password)?;
|
||||
if bytes.len() > MAX_BACKUP_BYTES {
|
||||
return Err(AppError::Config("Backup exceeds 50 MiB".into()));
|
||||
}
|
||||
let decoded = zeroize::Zeroizing::new(backup_crypto::decrypt_snapshot_bytes(bytes, password)?);
|
||||
let mut snapshot = decode_portable_snapshot(&decoded)?;
|
||||
if snapshot.snapshot_kind != PortableSnapshotKind::Backup {
|
||||
return Err(AppError::Config("Expected a configuration backup".into()));
|
||||
}
|
||||
storage::backup::validate_backup_data(&snapshot)?;
|
||||
let source = crypto::backup_key(
|
||||
snapshot
|
||||
.master_key_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::Crypto("Backup key missing".into()))?,
|
||||
password,
|
||||
)?;
|
||||
transcode(&mut snapshot, &source, &crypto::get_master_key()?)?;
|
||||
// Keep the destination key token and master password. AI fields in portable settings are plaintext.
|
||||
snapshot.master_key_token = storage::load_master_key_token()?;
|
||||
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
/// Merge target-only settings immediately before the transaction, under the instance lock.
|
||||
pub fn settings_for_import(snapshot: &PortableSnapshot) -> AppResult<config::AppSettings> {
|
||||
let mut settings = snapshot.settings.clone().apply_to(
|
||||
config::load_app_settings(&())?,
|
||||
&PortableSnapshotKind::Backup,
|
||||
);
|
||||
settings.ai = config::encrypt_ai_settings(settings.ai)?;
|
||||
settings.cloud_sync = config::encrypt_cloud_sync_settings(settings.cloud_sync)?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> AppResult<()> {
|
||||
if password.is_empty() || password.len() > 1024 {
|
||||
return Err(AppError::Config(
|
||||
"Backup password must contain 1–1024 bytes".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
use aes_gcm::aead::{Aead, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"nyaterm-cloud-snapshot-v1:";
|
||||
const LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"dragonfly-cloud-snapshot-v1:";
|
||||
|
||||
fn derive_snapshot_key_with_prefix(prefix: &[u8], master_password: &str) -> Key<Aes256Gcm> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(prefix);
|
||||
hasher.update(master_password.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
*Key::<Aes256Gcm>::from_slice(&digest)
|
||||
}
|
||||
|
||||
fn derive_snapshot_key(master_password: &str) -> Key<Aes256Gcm> {
|
||||
derive_snapshot_key_with_prefix(CLOUD_SNAPSHOT_KEY_PREFIX, master_password)
|
||||
}
|
||||
|
||||
fn derive_legacy_snapshot_key(master_password: &str) -> Key<Aes256Gcm> {
|
||||
derive_snapshot_key_with_prefix(LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX, master_password)
|
||||
}
|
||||
|
||||
pub fn encrypt_snapshot_bytes(plaintext: &[u8], master_password: &str) -> AppResult<Vec<u8>> {
|
||||
let key = derive_snapshot_key(&master_password);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|error| AppError::Crypto(format!("cloud snapshot encryption failed: {error}")))?;
|
||||
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
pub fn decrypt_snapshot_bytes(ciphertext: &[u8], master_password: &str) -> AppResult<Vec<u8>> {
|
||||
if ciphertext.len() < 13 {
|
||||
return Err(AppError::Crypto(
|
||||
"cloud snapshot ciphertext is too short".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
match decrypt_snapshot_bytes_with_key(ciphertext, &derive_snapshot_key(&master_password)) {
|
||||
Ok(plaintext) => Ok(plaintext),
|
||||
Err(new_error) => decrypt_snapshot_bytes_with_key(
|
||||
ciphertext,
|
||||
&derive_legacy_snapshot_key(&master_password),
|
||||
)
|
||||
.map_err(|legacy_error| {
|
||||
AppError::Crypto(format!(
|
||||
"cloud snapshot decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt_snapshot_bytes_with_key(ciphertext: &[u8], key: &Key<Aes256Gcm>) -> AppResult<Vec<u8>> {
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let (nonce_bytes, payload) = ciphertext.split_at(12);
|
||||
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, payload)
|
||||
.map_err(|error| AppError::Crypto(format!("cloud snapshot decryption failed: {error}")))
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+21
-23
@@ -1,7 +1,11 @@
|
||||
fn build_group_path(groups: &[Group], id: &str) -> Vec<String> {
|
||||
let mut segments = Vec::new();
|
||||
let mut current = id;
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
loop {
|
||||
if !visited.insert(current) {
|
||||
break;
|
||||
}
|
||||
if let Some(g) = groups.iter().find(|g| g.id == current) {
|
||||
segments.push(g.name.clone());
|
||||
if let Some(ref pid) = g.parent_id {
|
||||
@@ -65,10 +69,7 @@ fn ensure_group_path(
|
||||
Some(leaf_id)
|
||||
}
|
||||
|
||||
fn import_legacy_sessions(
|
||||
app: &tauri::AppHandle,
|
||||
imported: Vec<ImportedSession>,
|
||||
) -> AppResult<usize> {
|
||||
fn import_legacy_sessions(app: &impl Sized, imported: Vec<ImportedSession>) -> AppResult<usize> {
|
||||
if imported.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -134,7 +135,7 @@ fn import_legacy_sessions(
|
||||
}
|
||||
|
||||
fn import_prepared_nyaterm_json(
|
||||
app: &tauri::AppHandle,
|
||||
app: &impl Sized,
|
||||
prepared: PreparedJsonImport,
|
||||
) -> AppResult<usize> {
|
||||
if prepared.connections.is_empty() {
|
||||
@@ -182,13 +183,18 @@ fn import_prepared_nyaterm_json(
|
||||
|
||||
let mut passwords = config::load_passwords(app)?;
|
||||
passwords.passwords.extend(prepared.passwords);
|
||||
config::save_passwords(app, &passwords)?;
|
||||
|
||||
let mut keys = config::load_keys(app)?;
|
||||
keys.keys.extend(prepared.ssh_keys);
|
||||
config::save_keys(app, &keys)?;
|
||||
|
||||
config::save_config(app, &cfg)?;
|
||||
crate::storage::backup::import_connections(
|
||||
&config::SessionsConfig {
|
||||
connections: cfg.connections,
|
||||
groups: cfg.groups,
|
||||
custom_icons: cfg.custom_icons,
|
||||
},
|
||||
&passwords,
|
||||
&keys,
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -216,9 +222,9 @@ fn parse_json_import_content(content: &str) -> AppResult<PreparedJsonImport> {
|
||||
}
|
||||
|
||||
fn is_electerm_bookmarks_json(value: &serde_json::Value) -> bool {
|
||||
value
|
||||
.as_object()
|
||||
.is_some_and(|object| object.contains_key("bookmarkGroups") && object.contains_key("bookmarks"))
|
||||
value.as_object().is_some_and(|object| {
|
||||
object.contains_key("bookmarkGroups") && object.contains_key("bookmarks")
|
||||
})
|
||||
}
|
||||
|
||||
fn is_nyaterm_json_import(value: &serde_json::Value) -> bool {
|
||||
@@ -234,16 +240,13 @@ fn is_nyaterm_json_import(value: &serde_json::Value) -> bool {
|
||||
// ── Tauri Command ───────────────────────────────────────────────────────────
|
||||
|
||||
pub fn import_sessions(
|
||||
app: tauri::AppHandle,
|
||||
app: &impl Sized,
|
||||
file_path: String,
|
||||
windterm_master_password: Option<String>,
|
||||
) -> AppResult<usize> {
|
||||
let path = Path::new(&file_path);
|
||||
if path.is_dir() {
|
||||
let count = import_legacy_sessions(&app, parse_finalshell(&file_path)?)?;
|
||||
if count > 0 {
|
||||
let _ = app.emit("connections-changed", ());
|
||||
}
|
||||
return Ok(count);
|
||||
}
|
||||
|
||||
@@ -268,19 +271,14 @@ pub fn import_sessions(
|
||||
));
|
||||
};
|
||||
|
||||
if count > 0 {
|
||||
let _ = app.emit("connections-changed", ());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
pub fn import_termius_sessions(
|
||||
app: tauri::AppHandle,
|
||||
app: &impl Sized,
|
||||
indexed_db_path: Option<String>,
|
||||
) -> AppResult<usize> {
|
||||
let count = import_prepared_nyaterm_json(&app, parse_termius_indexed_db(indexed_db_path)?)?;
|
||||
if count > 0 {
|
||||
let _ = app.emit("connections-changed", ());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
fn parse_mobaxterm(path: &str) -> AppResult<Vec<ImportedSession>> {
|
||||
let raw =
|
||||
std::fs::read(path).map_err(|e| AppError::Config(format!("Cannot read file: {e}")))?;
|
||||
let content = decode_bytes(&raw);
|
||||
parse_mobaxterm_bytes(&raw)
|
||||
}
|
||||
|
||||
fn parse_mobaxterm_bytes(raw: &[u8]) -> AppResult<Vec<ImportedSession>> {
|
||||
let content = decode_bytes(raw);
|
||||
|
||||
let sections = parse_ini_sections(&content);
|
||||
let mut sessions = Vec::new();
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Import sessions from Xshell (.xts), MobaXterm (.mxtsessions), WindTerm (.sessions),
|
||||
//! SecureCRT (.xml), FinalShell conn directories, NyaTerm JSON files, and Electerm bookmarks.
|
||||
|
||||
use crate::config::{
|
||||
self, AiExecutionProfile, ConnectionAuth, ConnectionType, Group, SavedConnection,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
#[cfg(not(test))]
|
||||
use crate::utils::crypto;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn encrypt_import_secret(plaintext: &str) -> AppResult<String> {
|
||||
crypto::encrypt(plaintext)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn encrypt_import_secret(plaintext: &str) -> AppResult<String> {
|
||||
Ok(format!("test-encrypted:{plaintext}"))
|
||||
}
|
||||
|
||||
include!("types.rs");
|
||||
include!("text.rs");
|
||||
include!("common.rs");
|
||||
include!("xshell.rs");
|
||||
include!("mobaxterm.rs");
|
||||
include!("windterm.rs");
|
||||
include!("securecrt.rs");
|
||||
include!("finalshell.rs");
|
||||
include!("nyaterm_json.rs");
|
||||
include!("electerm.rs");
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
include!("termius.rs");
|
||||
include!("merge.rs");
|
||||
include!("tests.rs");
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct BrowserImportResult {
|
||||
pub imported: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Content-only import: never resolves paths from uploaded files on the server.
|
||||
pub fn import_browser_connections(source: &str, bytes: &[u8]) -> AppResult<BrowserImportResult> {
|
||||
if bytes.len() > 10 * 1024 * 1024 {
|
||||
return Err(AppError::Config("Import exceeds 10 MiB".into()));
|
||||
}
|
||||
let content = decode_bytes(bytes);
|
||||
let mut warnings = Vec::new();
|
||||
let imported = match source {
|
||||
"xshell" => {
|
||||
warnings.push("credentials_not_in_export".into());
|
||||
import_legacy_sessions(&(), parse_xshell_reader(std::io::Cursor::new(bytes))?)?
|
||||
}
|
||||
"mobaxterm" => {
|
||||
warnings.push("credentials_not_in_export".into());
|
||||
import_legacy_sessions(&(), parse_mobaxterm_bytes(bytes)?)?
|
||||
}
|
||||
"securecrt" => {
|
||||
warnings.push("credentials_not_in_export".into());
|
||||
import_legacy_sessions(&(), parse_securecrt_content(&content)?)?
|
||||
}
|
||||
"windterm" => {
|
||||
let entries: Vec<serde_json::Value> = serde_json::from_str(&content)?;
|
||||
// Encrypted auto-login and external key files require Desktop's profile resources.
|
||||
for entry in &entries {
|
||||
let login = entry.get("session.autoLogin");
|
||||
let has_encrypted = login.and_then(|v| v.as_str()).is_some_and(|v| {
|
||||
!v.trim().is_empty() && serde_json::from_str::<serde_json::Value>(v).is_err()
|
||||
});
|
||||
let parsed_login = login
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok());
|
||||
let login_value = parsed_login.as_ref().or(login);
|
||||
let has_key = entry.as_object().is_some_and(|fields| {
|
||||
fields.iter().any(|(key, value)| {
|
||||
key.starts_with("ssh.identityFilePath")
|
||||
&& value.as_str().is_some_and(|path| !path.trim().is_empty())
|
||||
})
|
||||
}) || login_value
|
||||
.and_then(|v| v.get("Public Key"))
|
||||
.and_then(|v| v.as_object())
|
||||
.is_some_and(|fields| {
|
||||
fields.iter().any(|(key, value)| {
|
||||
key.ends_with(".path")
|
||||
&& value.as_str().is_some_and(|path| !path.trim().is_empty())
|
||||
})
|
||||
});
|
||||
if has_encrypted || has_key {
|
||||
return Err(AppError::Config("WindTerm profile resources required; import on Desktop and transfer a .nya backup".into()));
|
||||
}
|
||||
}
|
||||
import_prepared_nyaterm_json(
|
||||
&(),
|
||||
parse_windterm_content_impl(&content, None, None, false)?,
|
||||
)?
|
||||
}
|
||||
"electerm" => import_prepared_nyaterm_json(&(), parse_electerm_json_content(&content)?)?,
|
||||
"nyaterm_json" => import_prepared_nyaterm_json(&(), parse_nyaterm_json_content(&content)?)?,
|
||||
_ => {
|
||||
return Err(AppError::Config(
|
||||
"This import source requires Desktop".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(BrowserImportResult { imported, warnings })
|
||||
}
|
||||
+1
-4
@@ -361,10 +361,7 @@ fn prepare_json_ssh_auth(
|
||||
})
|
||||
}
|
||||
"agent" => {
|
||||
if auth.password.is_some()
|
||||
|| auth.password_ref.is_some()
|
||||
|| auth.key_ref.is_some()
|
||||
{
|
||||
if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: agent auth cannot include password, password_ref, or key_ref"
|
||||
)));
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
|
||||
#[derive(Default)]
|
||||
struct SecureCrtKeyFrame {
|
||||
@@ -105,7 +105,9 @@ fn securecrt_session_from_frame(
|
||||
frame: &SecureCrtKeyFrame,
|
||||
ancestors: &[SecureCrtKeyFrame],
|
||||
) -> Option<ImportedSession> {
|
||||
let sessions_index = ancestors.iter().position(|frame| frame.name == "Sessions")?;
|
||||
let sessions_index = ancestors
|
||||
.iter()
|
||||
.position(|frame| frame.name == "Sessions")?;
|
||||
let protocol = frame
|
||||
.fields
|
||||
.get("Protocol Name")
|
||||
+39
-34
@@ -196,10 +196,7 @@ fn load_termius_local_key() -> AppResult<Zeroizing<[u8; 32]>> {
|
||||
}
|
||||
}
|
||||
|
||||
let candidates = [
|
||||
("Termius/localKey", "localKey"),
|
||||
("Termius", "localKey"),
|
||||
];
|
||||
let candidates = [("Termius/localKey", "localKey"), ("Termius", "localKey")];
|
||||
|
||||
for (service, user) in candidates {
|
||||
match keyring::Entry::new(service, user).and_then(|entry| entry.get_secret()) {
|
||||
@@ -297,10 +294,8 @@ fn normalize_termius_local_key_candidates(
|
||||
}
|
||||
|
||||
fn read_leveldb_records(db_path: &Path) -> AppResult<Vec<u8>> {
|
||||
let tmp_path = std::env::temp_dir().join(format!(
|
||||
"nyaterm-termius-leveldb-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let tmp_path =
|
||||
std::env::temp_dir().join(format!("nyaterm-termius-leveldb-{}", uuid::Uuid::new_v4()));
|
||||
copy_leveldb_dir(db_path, &tmp_path)?;
|
||||
|
||||
let result = read_copied_leveldb_records(&tmp_path);
|
||||
@@ -516,7 +511,12 @@ fn is_record_marker(strings: &[TermiusTaggedValue], index: usize) -> bool {
|
||||
}
|
||||
|
||||
fn collect_host_record(strings: &[TermiusTaggedValue], index: usize) -> Option<TermiusRawHost> {
|
||||
let record = collect_fields(strings, index, &["address", "label", "username", "password"], 80);
|
||||
let record = collect_fields(
|
||||
strings,
|
||||
index,
|
||||
&["address", "label", "username", "password"],
|
||||
80,
|
||||
);
|
||||
let address = record.get("address").cloned();
|
||||
let label = record.get("label").cloned();
|
||||
if address.as_ref().is_none_or(|value| value.trim().is_empty())
|
||||
@@ -562,7 +562,10 @@ fn collect_identity_record(
|
||||
index: usize,
|
||||
) -> Option<TermiusRawIdentity> {
|
||||
let record = collect_fields(strings, index, &["label", "username", "password"], 80);
|
||||
if !record.contains_key("username") && !record.contains_key("password") && !record.contains_key("ssh_key") {
|
||||
if !record.contains_key("username")
|
||||
&& !record.contains_key("password")
|
||||
&& !record.contains_key("ssh_key")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -631,7 +634,9 @@ fn collect_fields(
|
||||
.as_str()
|
||||
.is_some_and(|text| is_termius_field_name(text) || is_record_type_name(text));
|
||||
if !is_nested_name {
|
||||
fields.entry(key.to_string()).or_insert(value.to_field_value());
|
||||
fields
|
||||
.entry(key.to_string())
|
||||
.or_insert(value.to_field_value());
|
||||
}
|
||||
}
|
||||
cursor += 1;
|
||||
@@ -675,8 +680,7 @@ fn is_record_type_name(value: &str) -> bool {
|
||||
fn is_termius_field_name(value: &str) -> bool {
|
||||
matches!(
|
||||
value,
|
||||
"id"
|
||||
| "local_id"
|
||||
"id" | "local_id"
|
||||
| "updated_at"
|
||||
| "label"
|
||||
| "address"
|
||||
@@ -722,17 +726,11 @@ fn record_id(strings: &[TermiusTaggedValue], index: usize) -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
fn nested_record_id(
|
||||
strings: &[TermiusTaggedValue],
|
||||
index: usize,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
fn nested_record_id(strings: &[TermiusTaggedValue], index: usize, field: &str) -> Option<String> {
|
||||
let start = record_start(strings, index);
|
||||
let end = record_end(strings, index, 80);
|
||||
for cursor in start..end.saturating_sub(2) {
|
||||
if strings[cursor].as_str() == Some(field)
|
||||
&& strings[cursor + 1].as_str() == Some("id")
|
||||
{
|
||||
if strings[cursor].as_str() == Some(field) && strings[cursor + 1].as_str() == Some("id") {
|
||||
let value = strings[cursor + 2].to_field_value();
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
@@ -815,7 +813,9 @@ fn dedupe_latest_ssh_configs(configs: Vec<TermiusRawSshConfig>) -> Vec<TermiusRa
|
||||
}
|
||||
|
||||
fn dedupe_latest_identities(identities: Vec<TermiusRawIdentity>) -> Vec<TermiusRawIdentity> {
|
||||
dedupe_latest(identities, termius_identity_key, |item| item.updated_at.as_deref())
|
||||
dedupe_latest(identities, termius_identity_key, |item| {
|
||||
item.updated_at.as_deref()
|
||||
})
|
||||
}
|
||||
|
||||
fn dedupe_latest_ssh_keys(keys: Vec<TermiusRawSshKey>) -> Vec<TermiusRawSshKey> {
|
||||
@@ -884,8 +884,10 @@ fn termius_group_key(group: &TermiusRawGroup) -> String {
|
||||
}
|
||||
|
||||
fn build_termius_group_paths(groups: &[TermiusRawGroup]) -> HashMap<String, Vec<String>> {
|
||||
let by_key: HashMap<String, &TermiusRawGroup> =
|
||||
groups.iter().map(|group| (termius_group_key(group), group)).collect();
|
||||
let by_key: HashMap<String, &TermiusRawGroup> = groups
|
||||
.iter()
|
||||
.map(|group| (termius_group_key(group), group))
|
||||
.collect();
|
||||
let mut paths = HashMap::new();
|
||||
for group in groups {
|
||||
let key = termius_group_key(group);
|
||||
@@ -907,12 +909,12 @@ fn build_termius_group_path(
|
||||
}
|
||||
let group = by_key.get(key)?;
|
||||
let label = normalize_optional_string(group.label.clone())?;
|
||||
let mut path = if let Some(parent) = group.parent_id.as_deref().filter(|value| !value.is_empty())
|
||||
{
|
||||
build_termius_group_path(parent, by_key, visited).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut path =
|
||||
if let Some(parent) = group.parent_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
build_termius_group_path(parent, by_key, visited).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
path.push(label);
|
||||
Some(path)
|
||||
}
|
||||
@@ -994,7 +996,10 @@ fn prepare_termius_passwords(
|
||||
for identity in identities {
|
||||
if let Some(password) = normalize_optional_string(identity.password.clone()) {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
ids.insert(format!("identity:{}", termius_identity_key(identity)), id.clone());
|
||||
ids.insert(
|
||||
format!("identity:{}", termius_identity_key(identity)),
|
||||
id.clone(),
|
||||
);
|
||||
for alias in termius_record_aliases(&identity.id, identity.local_id.as_deref()) {
|
||||
ids.insert(format!("identity:{alias}"), id.clone());
|
||||
}
|
||||
@@ -1052,8 +1057,7 @@ fn prepare_termius_connections(
|
||||
.identity_id
|
||||
.as_deref()
|
||||
.or_else(|| ssh_config.and_then(|config| config.identity_id.as_deref()));
|
||||
let identity = identity_id
|
||||
.and_then(|id| identities_by_key.get(id).copied());
|
||||
let identity = identity_id.and_then(|id| identities_by_key.get(id).copied());
|
||||
let username = normalize_optional_string(host.username.clone())
|
||||
.or_else(|| identity.and_then(|item| normalize_optional_string(item.username.clone())))
|
||||
.unwrap_or_else(|| "root".to_string());
|
||||
@@ -1147,7 +1151,8 @@ fn prepare_termius_auth(
|
||||
}
|
||||
|
||||
let host_password_key = format!("host:{}", termius_host_key(host));
|
||||
let identity_password_key = identity.map(|item| format!("identity:{}", termius_identity_key(item)));
|
||||
let identity_password_key =
|
||||
identity.map(|item| format!("identity:{}", termius_identity_key(item)));
|
||||
let password_id = password_ids
|
||||
.get(&host_password_key)
|
||||
.cloned()
|
||||
+34
-17
@@ -1,4 +1,3 @@
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -343,7 +342,10 @@ mod tests {
|
||||
ConnectionType::Ssh { host, username, .. }
|
||||
if host == "bad.example.com" && username == "fallback"
|
||||
));
|
||||
assert_eq!(prepared.connections[0].auth.as_ref().expect("auth").mode, "none");
|
||||
assert_eq!(
|
||||
prepared.connections[0].auth.as_ref().expect("auth").mode,
|
||||
"none"
|
||||
);
|
||||
assert!(matches!(
|
||||
&prepared.connections[1].config,
|
||||
ConnectionType::Ssh { host, username, .. }
|
||||
@@ -646,10 +648,9 @@ mod tests {
|
||||
|
||||
let mut buffer = vec![0_u8; plaintext.len() + WINDTERM_AES_IV_LENGTH];
|
||||
buffer[..plaintext.len()].copy_from_slice(plaintext.as_bytes());
|
||||
let ciphertext =
|
||||
cbc::Encryptor::<aes::Aes256>::new(&crypto.key.into(), &crypto.iv.into())
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext.len())
|
||||
.expect("encrypt windterm payload");
|
||||
let ciphertext = cbc::Encryptor::<aes::Aes256>::new(&crypto.key.into(), &crypto.iv.into())
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buffer, plaintext.len())
|
||||
.expect("encrypt windterm payload");
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ciphertext)
|
||||
}
|
||||
|
||||
@@ -854,6 +855,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
#[test]
|
||||
fn termius_normalizes_local_key_variants() {
|
||||
use base64::Engine as _;
|
||||
@@ -869,10 +871,7 @@ mod tests {
|
||||
let key = normalize_termius_local_key_bytes(raw.as_bytes()).expect("raw bytes key");
|
||||
assert_eq!(key.as_ref(), raw.as_bytes());
|
||||
|
||||
let utf16le: Vec<u8> = raw
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_le_bytes)
|
||||
.collect();
|
||||
let utf16le: Vec<u8> = raw.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let key = normalize_termius_local_key_bytes(&utf16le).expect("utf16 key");
|
||||
assert_eq!(key.as_ref(), raw.as_bytes());
|
||||
|
||||
@@ -881,6 +880,7 @@ mod tests {
|
||||
assert_eq!(key.as_ref(), raw.as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
#[test]
|
||||
fn termius_decrypts_secretbox_values_and_rejects_corruption() {
|
||||
let key = *b"12345678901234567890123456789012";
|
||||
@@ -896,6 +896,7 @@ mod tests {
|
||||
assert!(error.to_string().contains("Cannot decrypt Termius"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
#[test]
|
||||
fn termius_tagged_string_parser_handles_multibyte_lengths() {
|
||||
let long = "x".repeat(300);
|
||||
@@ -907,6 +908,7 @@ mod tests {
|
||||
assert_eq!(strings, vec!["private_key".to_string(), long]);
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
#[test]
|
||||
fn termius_maps_host_ports_from_linked_ssh_configs() {
|
||||
let mut bytes = Vec::new();
|
||||
@@ -1031,7 +1033,10 @@ mod tests {
|
||||
|
||||
let prepared = parse_json_import_content(json).expect("parse electerm");
|
||||
|
||||
assert_eq!(prepared.groups, vec![vec!["Prod".to_string(), "Web".to_string()]]);
|
||||
assert_eq!(
|
||||
prepared.groups,
|
||||
vec![vec!["Prod".to_string(), "Web".to_string()]]
|
||||
);
|
||||
assert_eq!(
|
||||
prepared.connections[0].group_path,
|
||||
Some(vec!["Prod".to_string(), "Web".to_string()])
|
||||
@@ -1088,6 +1093,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
#[test]
|
||||
fn termius_maps_hosts_groups_passwords_and_keys() {
|
||||
crate::utils::crypto::set_master_password(None);
|
||||
@@ -1166,17 +1172,28 @@ mod tests {
|
||||
Some(vec!["Production".to_string()])
|
||||
);
|
||||
|
||||
let password_auth = prepared.connections[1].auth.as_ref().expect("password auth");
|
||||
let password_auth = prepared.connections[1]
|
||||
.auth
|
||||
.as_ref()
|
||||
.expect("password auth");
|
||||
assert_eq!(password_auth.mode, "password");
|
||||
assert!(password_auth.account_id.is_some());
|
||||
assert!(password_auth.password_id.is_none());
|
||||
assert!(prepared.passwords.iter().any(|entry| entry.username == "root"));
|
||||
assert!(prepared
|
||||
.passwords
|
||||
.iter()
|
||||
.any(|entry| entry.username == "deploy"));
|
||||
assert!(
|
||||
prepared
|
||||
.passwords
|
||||
.iter()
|
||||
.any(|entry| entry.username == "root")
|
||||
);
|
||||
assert!(
|
||||
prepared
|
||||
.passwords
|
||||
.iter()
|
||||
.any(|entry| entry.username == "deploy")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "desktop-importer")]
|
||||
#[test]
|
||||
fn termius_identity_password_keeps_host_username() {
|
||||
crate::utils::crypto::set_master_password(None);
|
||||
File renamed without changes.
-1
@@ -209,4 +209,3 @@ fn default_serial_stop_bits() -> String {
|
||||
fn default_serial_backspace_mode() -> String {
|
||||
"ctrl_h".to_string()
|
||||
}
|
||||
|
||||
+45
-19
@@ -34,6 +34,15 @@ fn parse_windterm_content_with_crypto(
|
||||
content: &str,
|
||||
crypto: Option<&WindtermCrypto>,
|
||||
source_path: Option<&Path>,
|
||||
) -> AppResult<PreparedJsonImport> {
|
||||
parse_windterm_content_impl(content, crypto, source_path, true)
|
||||
}
|
||||
|
||||
fn parse_windterm_content_impl(
|
||||
content: &str,
|
||||
crypto: Option<&WindtermCrypto>,
|
||||
source_path: Option<&Path>,
|
||||
allow_local_files: bool,
|
||||
) -> AppResult<PreparedJsonImport> {
|
||||
let entries: Vec<serde_json::Value> = serde_json::from_str(&content)
|
||||
.map_err(|e| AppError::Config(format!("Invalid WindTerm JSON: {e}")))?;
|
||||
@@ -118,6 +127,7 @@ fn parse_windterm_content_with_crypto(
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false);
|
||||
let auth = prepare_windterm_auth(
|
||||
allow_local_files,
|
||||
entry,
|
||||
auto_login.as_ref(),
|
||||
source_path,
|
||||
@@ -252,17 +262,15 @@ fn parse_windterm_auto_login(
|
||||
let plaintext = String::from_utf8(plaintext).map_err(|_| {
|
||||
AppError::Config("Failed to decode decrypted WindTerm autoLogin text".to_string())
|
||||
})?;
|
||||
let value: serde_json::Value = serde_json::from_str(&plaintext)
|
||||
.map_err(|_| AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string()))?;
|
||||
let value: serde_json::Value = serde_json::from_str(&plaintext).map_err(|_| {
|
||||
AppError::Config("Failed to parse decrypted WindTerm autoLogin JSON".to_string())
|
||||
})?;
|
||||
value.as_object().cloned().map(Some).ok_or_else(|| {
|
||||
AppError::Config("WindTerm session.autoLogin must decode to a JSON object".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_windterm_auto_login(
|
||||
ciphertext: &[u8],
|
||||
crypto: &WindtermCrypto,
|
||||
) -> AppResult<Vec<u8>> {
|
||||
fn decrypt_windterm_auto_login(ciphertext: &[u8], crypto: &WindtermCrypto) -> AppResult<Vec<u8>> {
|
||||
if ciphertext.is_empty() || ciphertext.len() % WINDTERM_AES_IV_LENGTH != 0 {
|
||||
return Err(AppError::Config(
|
||||
"Invalid WindTerm autoLogin ciphertext length".to_string(),
|
||||
@@ -294,6 +302,7 @@ fn parse_windterm_group_path(entry: &serde_json::Value) -> Option<Vec<String>> {
|
||||
}
|
||||
|
||||
fn prepare_windterm_auth(
|
||||
allow_local_files: bool,
|
||||
entry: &serde_json::Value,
|
||||
auto_login: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
source_path: Option<&Path>,
|
||||
@@ -328,7 +337,16 @@ fn prepare_windterm_auth(
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(key_id) = import_windterm_key(entry, auto_login, source_path, session_name, ssh_keys, key_ids)? {
|
||||
if allow_local_files
|
||||
&& let Some(key_id) = import_windterm_key(
|
||||
entry,
|
||||
auto_login,
|
||||
source_path,
|
||||
session_name,
|
||||
ssh_keys,
|
||||
key_ids,
|
||||
)?
|
||||
{
|
||||
return Ok(ConnectionAuth {
|
||||
mode: "key".to_string(),
|
||||
account_id: None,
|
||||
@@ -386,17 +404,15 @@ fn import_windterm_key(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some((key_path, resolved_path, key_content)) =
|
||||
key_paths.into_iter().find_map(|path| {
|
||||
let resolved = resolve_windterm_key_path(path, source_path)?;
|
||||
let content = std::fs::read_to_string(&resolved).ok()?;
|
||||
if content.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((path, resolved, content))
|
||||
}
|
||||
})
|
||||
else {
|
||||
let Some((key_path, resolved_path, key_content)) = key_paths.into_iter().find_map(|path| {
|
||||
let resolved = resolve_windterm_key_path(path, source_path)?;
|
||||
let content = std::fs::read_to_string(&resolved).ok()?;
|
||||
if content.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((path, resolved, content))
|
||||
}
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -412,6 +428,13 @@ fn import_windterm_key(
|
||||
if let Some(id) = key_ids.get(&dedupe_key) {
|
||||
return Ok(Some(id.clone()));
|
||||
}
|
||||
if passphrase.is_none()
|
||||
&& let Some((_, id)) = key_ids
|
||||
.iter()
|
||||
.find(|((path, _), _)| path == &normalized_path)
|
||||
{
|
||||
return Ok(Some(id.clone()));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let display_name = Path::new(key_path)
|
||||
@@ -457,7 +480,10 @@ fn resolve_windterm_key_path(path: &str, source_path: Option<&Path>) -> Option<s
|
||||
.replace("${HomeDir}", &home_str);
|
||||
if expanded == "~" {
|
||||
expanded = home_str.to_string();
|
||||
} else if let Some(rest) = expanded.strip_prefix("~/").or_else(|| expanded.strip_prefix("~\\")) {
|
||||
} else if let Some(rest) = expanded
|
||||
.strip_prefix("~/")
|
||||
.or_else(|| expanded.strip_prefix("~\\"))
|
||||
{
|
||||
expanded = home.join(rest).to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -1,13 +1,21 @@
|
||||
fn parse_xshell(path: &str) -> AppResult<Vec<ImportedSession>> {
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| AppError::Config(format!("Cannot open file: {e}")))?;
|
||||
parse_xshell_reader(file)
|
||||
}
|
||||
|
||||
fn parse_xshell_reader(file: impl Read + std::io::Seek) -> AppResult<Vec<ImportedSession>> {
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| AppError::Config(format!("Invalid ZIP/XTS file: {e}")))?;
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
|
||||
let mut expanded_bytes = 0u64;
|
||||
if archive.len() > 10_000 {
|
||||
return Err(AppError::Config("Too many archive entries".into()));
|
||||
}
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
let entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| AppError::Config(format!("ZIP entry error: {e}")))?;
|
||||
|
||||
@@ -19,10 +27,18 @@ fn parse_xshell(path: &str) -> AppResult<Vec<ImportedSession>> {
|
||||
}
|
||||
|
||||
let mut raw = Vec::new();
|
||||
expanded_bytes = expanded_bytes.saturating_add(entry.size());
|
||||
if expanded_bytes > 50 * 1024 * 1024 {
|
||||
return Err(AppError::Config("Archive exceeds 50 MiB".into()));
|
||||
}
|
||||
entry
|
||||
.take(50 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut raw)
|
||||
.map_err(|e| AppError::Config(format!("Failed to read {entry_path}: {e}")))?;
|
||||
|
||||
if raw.len() > 50 * 1024 * 1024 {
|
||||
return Err(AppError::Config("Archive exceeds 50 MiB".into()));
|
||||
}
|
||||
let content = decode_bytes(&raw);
|
||||
|
||||
if let Some(sess) = parse_xsh_content(&content, &entry_path) {
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::config;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct KeywordHighlightImportResult {
|
||||
pub imported_rules: usize,
|
||||
pub updated_rules: usize,
|
||||
pub total_rules: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum KeywordHighlightImportFile {
|
||||
Config {
|
||||
keyword_highlights: Vec<config::KeywordHighlightRule>,
|
||||
},
|
||||
Rules(Vec<config::KeywordHighlightRule>),
|
||||
}
|
||||
|
||||
pub fn parse_keyword_highlight_import(raw: &str) -> AppResult<Vec<config::KeywordHighlightRule>> {
|
||||
let import_file: KeywordHighlightImportFile = serde_json::from_str(raw)
|
||||
.map_err(|error| AppError::Config(format!("Invalid highlight rules JSON: {error}")))?;
|
||||
|
||||
Ok(match import_file {
|
||||
KeywordHighlightImportFile::Config { keyword_highlights } => keyword_highlights,
|
||||
KeywordHighlightImportFile::Rules(rules) => rules,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn merge_keyword_highlight_rules(
|
||||
existing: &mut Vec<config::KeywordHighlightRule>,
|
||||
imported: Vec<config::KeywordHighlightRule>,
|
||||
mut next_id: impl FnMut() -> String,
|
||||
) -> AppResult<KeywordHighlightImportResult> {
|
||||
let mut imported_rules = 0;
|
||||
let mut updated_rules = 0;
|
||||
let mut indexes = existing
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, rule)| (!rule.id.trim().is_empty()).then(|| (rule.id.clone(), index)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for mut rule in imported {
|
||||
rule.name = rule.name.trim().to_string();
|
||||
rule.patterns = rule
|
||||
.patterns
|
||||
.into_iter()
|
||||
.map(|pattern| pattern.trim().to_string())
|
||||
.filter(|pattern| !pattern.is_empty())
|
||||
.collect();
|
||||
|
||||
if rule.name.is_empty() || rule.patterns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
rule.id = rule.id.trim().to_string();
|
||||
if rule.id.is_empty() {
|
||||
rule.id = next_id();
|
||||
}
|
||||
|
||||
if let Some(index) = indexes.get(&rule.id).copied() {
|
||||
existing[index] = rule;
|
||||
updated_rules += 1;
|
||||
} else {
|
||||
let id = rule.id.clone();
|
||||
existing.push(rule);
|
||||
indexes.insert(id, existing.len() - 1);
|
||||
imported_rules += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if imported_rules == 0 && updated_rules == 0 {
|
||||
return Err(AppError::Config(
|
||||
"No valid highlight rules found in import file".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(KeywordHighlightImportResult {
|
||||
imported_rules,
|
||||
updated_rules,
|
||||
total_rules: existing.len(),
|
||||
})
|
||||
}
|
||||
@@ -6,3 +6,9 @@ pub mod capabilities;
|
||||
pub mod monitoring;
|
||||
pub mod quick_commands;
|
||||
pub mod translate;
|
||||
|
||||
pub mod backup;
|
||||
pub mod backup_crypto;
|
||||
pub mod importer;
|
||||
pub mod keyword_highlights;
|
||||
pub mod portable_snapshot;
|
||||
+38
-28
@@ -2,7 +2,7 @@ pub fn decode_portable_snapshot(bytes: &[u8]) -> AppResult<PortableSnapshot> {
|
||||
Ok(decode_portable_snapshot_with_source_hash(bytes)?.snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_portable_snapshot_with_source_hash(
|
||||
pub fn decode_portable_snapshot_with_source_hash(
|
||||
bytes: &[u8],
|
||||
) -> AppResult<DecodedPortableSnapshot> {
|
||||
let payload = catch_unwind(AssertUnwindSafe(|| -> AppResult<Vec<u8>> {
|
||||
@@ -29,34 +29,38 @@ fn decode_portable_snapshot_redb_with_source_hash(
|
||||
}
|
||||
|
||||
fn read_portable_snapshot_redb(bytes: &[u8]) -> AppResult<DecodedPortableSnapshot> {
|
||||
catch_unwind(AssertUnwindSafe(|| -> AppResult<DecodedPortableSnapshot> {
|
||||
let temp = TempRedbFile::new("portable-snapshot-decode");
|
||||
fs::write(temp.path(), bytes)?;
|
||||
let db = Database::open(temp.path()).map_err(storage_error)?;
|
||||
let read = db.begin_read().map_err(storage_error)?;
|
||||
let meta_table = read
|
||||
.open_table(SNAPSHOT_META_TABLE)
|
||||
.map_err(storage_error)?;
|
||||
let meta_raw = meta_table
|
||||
.get(SNAPSHOT_META_KEY)
|
||||
.map_err(storage_error)?
|
||||
.ok_or_else(|| AppError::Config("portable snapshot is missing metadata".to_string()))?
|
||||
.value()
|
||||
.to_string();
|
||||
let meta: PortableSnapshotMeta = serde_json::from_str(&meta_raw)?;
|
||||
catch_unwind(AssertUnwindSafe(
|
||||
|| -> AppResult<DecodedPortableSnapshot> {
|
||||
let temp = TempRedbFile::new("portable-snapshot-decode");
|
||||
fs::write(temp.path(), bytes)?;
|
||||
let db = Database::open(temp.path()).map_err(storage_error)?;
|
||||
let read = db.begin_read().map_err(storage_error)?;
|
||||
let meta_table = read
|
||||
.open_table(SNAPSHOT_META_TABLE)
|
||||
.map_err(storage_error)?;
|
||||
let meta_raw = meta_table
|
||||
.get(SNAPSHOT_META_KEY)
|
||||
.map_err(storage_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::Config("portable snapshot is missing metadata".to_string())
|
||||
})?
|
||||
.value()
|
||||
.to_string();
|
||||
let meta: PortableSnapshotMeta = serde_json::from_str(&meta_raw)?;
|
||||
|
||||
if meta.schema_version == 2 {
|
||||
decode_v2_snapshot(&read, meta)
|
||||
} else if meta.schema_version == PORTABLE_SNAPSHOT_SCHEMA_VERSION {
|
||||
let entities = read_string_table(&read, SNAPSHOT_ENTITIES_TABLE)?;
|
||||
decode_v3_snapshot(meta, &entities)
|
||||
} else {
|
||||
Err(AppError::Config(format!(
|
||||
"Unsupported portable snapshot version {}",
|
||||
meta.schema_version
|
||||
)))
|
||||
}
|
||||
}))
|
||||
if meta.schema_version == 2 {
|
||||
decode_v2_snapshot(&read, meta)
|
||||
} else if meta.schema_version == PORTABLE_SNAPSHOT_SCHEMA_VERSION {
|
||||
let entities = read_string_table(&read, SNAPSHOT_ENTITIES_TABLE)?;
|
||||
decode_v3_snapshot(meta, &entities)
|
||||
} else {
|
||||
Err(AppError::Config(format!(
|
||||
"Unsupported portable snapshot version {}",
|
||||
meta.schema_version
|
||||
)))
|
||||
}
|
||||
},
|
||||
))
|
||||
.unwrap_or_else(|_| {
|
||||
Err(AppError::Storage(
|
||||
"Portable snapshot redb payload is corrupt or incomplete".to_string(),
|
||||
@@ -146,6 +150,12 @@ pub(crate) fn encode_v3_raw_snapshot_redb_for_test(
|
||||
}
|
||||
|
||||
fn encode_compressed_snapshot_payload(redb_payload: &[u8]) -> AppResult<Vec<u8>> {
|
||||
// Do not produce an archive the shared decoder cannot restore.
|
||||
if redb_payload.len() as u64 > MAX_COMPRESSED_SNAPSHOT_PAYLOAD_BYTES {
|
||||
return Err(AppError::Config(
|
||||
"Snapshot exceeds the 50 MiB restore limit".into(),
|
||||
));
|
||||
}
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut zip = zip::ZipWriter::new(cursor);
|
||||
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
fn validate_portable_snapshot(snapshot: &PortableSnapshot) -> AppResult<()> {
|
||||
pub fn validate_portable_snapshot(snapshot: &PortableSnapshot) -> AppResult<()> {
|
||||
if snapshot.schema_version != PORTABLE_SNAPSHOT_SCHEMA_VERSION {
|
||||
return Err(AppError::Config(format!(
|
||||
"Unsupported portable snapshot version {}",
|
||||
@@ -14,7 +14,7 @@ fn validate_portable_snapshot(snapshot: &PortableSnapshot) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn calculate_payload_hash(snapshot: &PortableSnapshot) -> AppResult<String> {
|
||||
pub fn calculate_payload_hash(snapshot: &PortableSnapshot) -> AppResult<String> {
|
||||
let payload_bytes = serde_json::to_vec(&SnapshotHashInput {
|
||||
settings: &snapshot.settings,
|
||||
sessions: &snapshot.sessions,
|
||||
@@ -93,7 +93,9 @@ fn log_snapshot_hash_normalized(snapshot: &PortableSnapshot, source_payload_hash
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn calculate_v3_raw_payload_hash(entities: &BTreeMap<String, String>) -> AppResult<String> {
|
||||
pub(crate) fn calculate_v3_raw_payload_hash(
|
||||
entities: &BTreeMap<String, String>,
|
||||
) -> AppResult<String> {
|
||||
let settings = read_raw_entity(entities, "settings")?;
|
||||
let sessions = read_raw_entity(entities, "sessions")?;
|
||||
let keys = read_raw_entity(entities, "keys")?;
|
||||
@@ -166,4 +168,3 @@ pub(crate) fn calculate_v3_raw_payload_hash(entities: &BTreeMap<String, String>)
|
||||
}
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,43 @@
|
||||
//! Portable backup wire format shared by Desktop and Web.
|
||||
use crate::config::{
|
||||
self, ActivityBarLayout, AppSettings, DiagnosticsSettings, InteractionSettings, SearchSettings,
|
||||
TerminalSettings, TransferSettings, TranslationSettings,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
io::{Cursor, Read, Write},
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use zip::write::SimpleFileOptions;
|
||||
pub const PORTABLE_SNAPSHOT_SCHEMA_VERSION: u32 = 3;
|
||||
const SNAPSHOT_META_KEY: &str = "meta";
|
||||
const SNAPSHOT_JSON_PORTABLE_SETTINGS: &str = "portable-settings";
|
||||
const SNAPSHOT_ZIP_MANIFEST_NAME: &str = "manifest.json";
|
||||
const SNAPSHOT_ZIP_PAYLOAD_NAME: &str = "snapshot.redb";
|
||||
const MAX_COMPRESSED_SNAPSHOT_PAYLOAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||
const SNAPSHOT_META_TABLE: TableDefinition<&str, &str> = TableDefinition::new("snapshot_meta");
|
||||
const SNAPSHOT_ENTITIES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("entity_docs");
|
||||
const SNAPSHOT_V2_JSON_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("json_docs");
|
||||
const SNAPSHOT_V2_TEXT_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("text_docs");
|
||||
include!("types.rs");
|
||||
include!("codec.rs");
|
||||
include!("hash.rs");
|
||||
include!("legacy.rs");
|
||||
include!("redb_codec.rs");
|
||||
include!("temp_redb.rs");
|
||||
include!("tests.rs");
|
||||
pub fn sync_settings_payload_changed(
|
||||
previous: &AppSettings,
|
||||
next: &AppSettings,
|
||||
) -> AppResult<bool> {
|
||||
let previous = PortableAppSettings::from_app_settings(previous, &PortableSnapshotKind::Sync);
|
||||
let next = PortableAppSettings::from_app_settings(next, &PortableSnapshotKind::Sync);
|
||||
Ok(serde_json::to_vec(&previous)? != serde_json::to_vec(&next)?)
|
||||
}
|
||||
+1
-1
@@ -53,7 +53,7 @@ fn read_string_table(
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn current_time_ms() -> u64 {
|
||||
pub fn current_time_ms() -> u64 {
|
||||
let millis = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
File renamed without changes.
+40
-27
@@ -2,12 +2,12 @@
|
||||
#[allow(deprecated)]
|
||||
mod tests {
|
||||
use super::{
|
||||
PORTABLE_SNAPSHOT_SCHEMA_VERSION, PortableAppSettings, PortableSnapshot,
|
||||
PortableSnapshotKind, PortableUiSettings, SNAPSHOT_ZIP_PAYLOAD_NAME,
|
||||
AgentEndpointTargetPlatform, PORTABLE_SNAPSHOT_SCHEMA_VERSION, PortableAppSettings,
|
||||
PortableSnapshot, PortableSnapshotKind, PortableUiSettings, SNAPSHOT_ZIP_PAYLOAD_NAME,
|
||||
calculate_payload_hash, calculate_v3_raw_payload_hash, encode_portable_snapshot,
|
||||
encode_portable_snapshot_redb, encode_v3_raw_snapshot_redb_for_test,
|
||||
normalize_backup_sessions_for_platform, preserve_device_local_sessions,
|
||||
strip_device_local_sessions, sync_settings_payload_changed, AgentEndpointTargetPlatform,
|
||||
strip_device_local_sessions, sync_settings_payload_changed,
|
||||
};
|
||||
use crate::config::{self, ActivityBarLayout, AppSettings};
|
||||
use crate::error::AppError;
|
||||
@@ -40,11 +40,13 @@ mod tests {
|
||||
}))
|
||||
.expect("sessions");
|
||||
|
||||
assert!(normalize_backup_sessions_for_platform(
|
||||
&mut sessions,
|
||||
AgentEndpointTargetPlatform::Unix
|
||||
)
|
||||
.expect("normalize backup sessions"));
|
||||
assert!(
|
||||
normalize_backup_sessions_for_platform(
|
||||
&mut sessions,
|
||||
AgentEndpointTargetPlatform::Unix
|
||||
)
|
||||
.expect("normalize backup sessions")
|
||||
);
|
||||
|
||||
let config::ConnectionType::Ssh {
|
||||
auth_agent_endpoint,
|
||||
@@ -90,11 +92,13 @@ mod tests {
|
||||
}))
|
||||
.expect("sessions");
|
||||
|
||||
assert!(normalize_backup_sessions_for_platform(
|
||||
&mut sessions,
|
||||
AgentEndpointTargetPlatform::Windows
|
||||
)
|
||||
.expect("normalize backup sessions"));
|
||||
assert!(
|
||||
normalize_backup_sessions_for_platform(
|
||||
&mut sessions,
|
||||
AgentEndpointTargetPlatform::Windows
|
||||
)
|
||||
.expect("normalize backup sessions")
|
||||
);
|
||||
|
||||
let config::ConnectionType::Ssh {
|
||||
auth_agent_endpoint,
|
||||
@@ -106,7 +110,10 @@ mod tests {
|
||||
};
|
||||
assert!(auth_agent_endpoint.is_none());
|
||||
let forwarding = agent_forwarding_config.as_ref().expect("forwarding config");
|
||||
assert_eq!(forwarding.sources.external_agent_endpoints, vec![config::SshAgentEndpoint::Pageant]);
|
||||
assert_eq!(
|
||||
forwarding.sources.external_agent_endpoints,
|
||||
vec![config::SshAgentEndpoint::Pageant]
|
||||
);
|
||||
assert!(forwarding.enabled);
|
||||
}
|
||||
|
||||
@@ -134,11 +141,13 @@ mod tests {
|
||||
}))
|
||||
.expect("sessions");
|
||||
|
||||
assert!(normalize_backup_sessions_for_platform(
|
||||
&mut sessions,
|
||||
AgentEndpointTargetPlatform::Windows
|
||||
)
|
||||
.is_err());
|
||||
assert!(
|
||||
normalize_backup_sessions_for_platform(
|
||||
&mut sessions,
|
||||
AgentEndpointTargetPlatform::Windows
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -519,7 +528,10 @@ mod tests {
|
||||
else {
|
||||
panic!("expected SSH connection");
|
||||
};
|
||||
assert_eq!(auth_agent_endpoint, &Some(config::SshAgentEndpoint::Pageant));
|
||||
assert_eq!(
|
||||
auth_agent_endpoint,
|
||||
&Some(config::SshAgentEndpoint::Pageant)
|
||||
);
|
||||
assert!(legacy_agent_forwarding.is_none());
|
||||
assert_eq!(
|
||||
agent_forwarding_config,
|
||||
@@ -760,7 +772,8 @@ mod tests {
|
||||
Some("2026-08-03T12:00:00.000Z")
|
||||
);
|
||||
assert_eq!(
|
||||
asset.accelerators
|
||||
asset
|
||||
.accelerators
|
||||
.as_ref()
|
||||
.and_then(|items| items.first())
|
||||
.map(|item| &item.r#type),
|
||||
@@ -1090,8 +1103,7 @@ mod tests {
|
||||
snapshot.payload_hash = calculate_payload_hash(&snapshot).expect("current snapshot hash");
|
||||
|
||||
let mut entities = snapshot_entities(&snapshot);
|
||||
let mut legacy_sessions =
|
||||
serde_json::to_value(&snapshot.sessions).expect("sessions json");
|
||||
let mut legacy_sessions = serde_json::to_value(&snapshot.sessions).expect("sessions json");
|
||||
let connection = legacy_sessions["connections"][0]
|
||||
.as_object_mut()
|
||||
.expect("connection object");
|
||||
@@ -1122,9 +1134,11 @@ mod tests {
|
||||
);
|
||||
let normalized_sessions =
|
||||
serde_json::to_value(&decoded.snapshot.sessions).expect("normalized sessions");
|
||||
assert!(normalized_sessions["connections"][0]["asset"]
|
||||
.get("tags")
|
||||
.is_none());
|
||||
assert!(
|
||||
normalized_sessions["connections"][0]["asset"]
|
||||
.get("tags")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1219,5 +1233,4 @@ mod tests {
|
||||
"compressed snapshot should be smaller than legacy redb"
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
+4
-4
@@ -6,13 +6,13 @@ pub enum PortableSnapshotKind {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AgentEndpointTargetPlatform {
|
||||
pub enum AgentEndpointTargetPlatform {
|
||||
Unix,
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl AgentEndpointTargetPlatform {
|
||||
pub(crate) fn current() -> Self {
|
||||
pub fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else {
|
||||
@@ -45,7 +45,7 @@ fn normalize_portable_session_tags(sessions: &mut config::SessionsConfig) -> boo
|
||||
}
|
||||
|
||||
/// Removes device-specific Agent endpoints that cannot work on the restore target.
|
||||
pub(crate) fn normalize_backup_sessions_for_platform(
|
||||
pub fn normalize_backup_sessions_for_platform(
|
||||
sessions: &mut config::SessionsConfig,
|
||||
platform: AgentEndpointTargetPlatform,
|
||||
) -> crate::error::AppResult<bool> {
|
||||
@@ -150,7 +150,7 @@ pub struct PortableSnapshot {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct DecodedPortableSnapshot {
|
||||
pub struct DecodedPortableSnapshot {
|
||||
pub snapshot: PortableSnapshot,
|
||||
pub source_payload_hash: String,
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use super::{
|
||||
SettingsDocKey, Storage, credentials::*, history::replace_command_history_in_txn,
|
||||
known_hosts::replace_known_hosts_text_in_txn, notes::replace_notes_in_txn,
|
||||
sessions::replace_sessions_in_txn, tables::SETTINGS_TABLE, util::*,
|
||||
};
|
||||
use crate::{config, core::portable_snapshot::PortableSnapshot, error::AppResult};
|
||||
|
||||
pub fn restore_backup(
|
||||
snapshot: &PortableSnapshot,
|
||||
settings: &config::AppSettings,
|
||||
) -> AppResult<()> {
|
||||
super::storage()?.restore_backup(snapshot, settings)
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn restore_backup(
|
||||
&self,
|
||||
snapshot: &PortableSnapshot,
|
||||
settings: &config::AppSettings,
|
||||
) -> AppResult<()> {
|
||||
crate::core::portable_snapshot::validate_portable_snapshot(snapshot)?;
|
||||
let txn = self.db.begin_write().map_err(storage_error)?;
|
||||
replace_sessions_in_txn(&txn, &snapshot.sessions)?;
|
||||
replace_passwords_in_txn(&txn, &snapshot.passwords)?;
|
||||
replace_ssh_keys_in_txn(&txn, &snapshot.keys)?;
|
||||
replace_credentials_in_txn(&txn, &snapshot.credentials)?;
|
||||
replace_otp_in_txn(&txn, &snapshot.otp)?;
|
||||
replace_proxies_in_txn(&txn, &snapshot.proxies)?;
|
||||
replace_tunnels_in_txn(&txn, &snapshot.tunnels)?;
|
||||
replace_command_history_in_txn(&txn, &snapshot.history)?;
|
||||
replace_notes_in_txn(&txn, &snapshot.notes)?;
|
||||
replace_known_hosts_text_in_txn(&txn, &snapshot.known_hosts)?;
|
||||
for (key, value) in [
|
||||
(SettingsDocKey::AppSettings, serde_json::to_value(settings)?),
|
||||
(
|
||||
SettingsDocKey::QuickCommands,
|
||||
serde_json::to_value(&snapshot.quick_commands)?,
|
||||
),
|
||||
(
|
||||
SettingsDocKey::ProxyGroups,
|
||||
serde_json::json!({"groups":snapshot.proxy_groups}),
|
||||
),
|
||||
(
|
||||
SettingsDocKey::TunnelGroups,
|
||||
serde_json::json!({"groups":snapshot.tunnel_groups}),
|
||||
),
|
||||
] {
|
||||
write_json_in_txn(&txn, SETTINGS_TABLE, key.storage_key(), &value)?;
|
||||
}
|
||||
let mut sync = self
|
||||
.get_settings_doc::<config::CloudSyncState>(SettingsDocKey::CloudSyncState)?
|
||||
.unwrap_or_default();
|
||||
sync.last_synced_payload_hash = None;
|
||||
sync.last_applied_remote_revision = None;
|
||||
write_json_in_txn(
|
||||
&txn,
|
||||
SETTINGS_TABLE,
|
||||
SettingsDocKey::CloudSyncState.storage_key(),
|
||||
&sync,
|
||||
)?;
|
||||
txn.commit().map_err(storage_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn import_connections(
|
||||
&self,
|
||||
sessions: &config::SessionsConfig,
|
||||
passwords: &config::PasswordsConfig,
|
||||
keys: &config::KeysConfig,
|
||||
) -> AppResult<()> {
|
||||
let txn = self.db.begin_write().map_err(storage_error)?;
|
||||
replace_sessions_in_txn(&txn, sessions)?;
|
||||
replace_passwords_in_txn(&txn, passwords)?;
|
||||
replace_ssh_keys_in_txn(&txn, keys)?;
|
||||
txn.commit().map_err(storage_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn import_connections(
|
||||
sessions: &config::SessionsConfig,
|
||||
passwords: &config::PasswordsConfig,
|
||||
keys: &config::KeysConfig,
|
||||
) -> AppResult<()> {
|
||||
super::storage()?.import_connections(sessions, passwords, keys)
|
||||
}
|
||||
|
||||
/// Reject ambiguous entity IDs before writes can silently replace another entity.
|
||||
pub fn validate_backup_data(snapshot: &PortableSnapshot) -> AppResult<()> {
|
||||
fn unique<'a>(ids: impl Iterator<Item = &'a str>) -> AppResult<()> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in ids {
|
||||
if id.trim().is_empty() || !seen.insert(id) {
|
||||
return Err(crate::error::AppError::Config(
|
||||
"Backup contains empty or duplicate entity IDs".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
macro_rules! check {
|
||||
($values:expr) => {
|
||||
unique($values.iter().map(|entry| entry.id.as_str()))?
|
||||
};
|
||||
}
|
||||
check!(snapshot.sessions.connections);
|
||||
check!(snapshot.sessions.groups);
|
||||
check!(snapshot.sessions.custom_icons);
|
||||
check!(snapshot.keys.keys);
|
||||
check!(snapshot.passwords.passwords);
|
||||
check!(snapshot.credentials.credentials);
|
||||
check!(snapshot.otp.entries);
|
||||
check!(snapshot.proxies);
|
||||
check!(snapshot.proxy_groups);
|
||||
check!(snapshot.tunnels);
|
||||
check!(snapshot.tunnel_groups);
|
||||
check!(snapshot.quick_commands.commands);
|
||||
check!(snapshot.quick_commands.categories);
|
||||
super::notes::validate_notes_snapshot(&snapshot.notes)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub mod backup;
|
||||
mod credentials;
|
||||
mod history;
|
||||
mod known_hosts;
|
||||
|
||||
@@ -436,23 +436,8 @@ impl Storage {
|
||||
}
|
||||
|
||||
pub fn replace_notes_snapshot(&self, snapshot: &NotesSnapshot) -> AppResult<()> {
|
||||
validate_notes_snapshot(snapshot)?;
|
||||
let txn = self.db.begin_write().map_err(storage_error)?;
|
||||
clear_prefix_in_txn(&txn, NOTE_FOLDERS_TABLE, NOTE_FOLDER_PREFIX)?;
|
||||
clear_prefix_in_txn(&txn, NOTES_TABLE, NOTE_DOCUMENT_PREFIX)?;
|
||||
clear_prefix_in_txn(&txn, NOTE_SUMMARIES_TABLE, NOTE_SUMMARY_PREFIX)?;
|
||||
for folder in &snapshot.folders {
|
||||
write_note_folder_in_txn(&txn, folder)?;
|
||||
}
|
||||
for note in &snapshot.notes {
|
||||
write_note_in_txn(&txn, note)?;
|
||||
write_note_summary_in_txn(&txn, &NoteSummary::from(note.clone()))?;
|
||||
}
|
||||
write_meta_u32(
|
||||
&txn,
|
||||
META_NOTE_SUMMARY_INDEX_VERSION,
|
||||
NOTE_SUMMARY_INDEX_VERSION,
|
||||
)?;
|
||||
replace_notes_in_txn(&txn, snapshot)?;
|
||||
txn.commit().map_err(storage_error)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -752,7 +737,7 @@ fn next_sort_order_for_parent(
|
||||
.saturating_add(1)
|
||||
}
|
||||
|
||||
fn validate_notes_snapshot(snapshot: &NotesSnapshot) -> AppResult<()> {
|
||||
pub(crate) fn validate_notes_snapshot(snapshot: &NotesSnapshot) -> AppResult<()> {
|
||||
let mut folder_ids = HashSet::new();
|
||||
let mut note_ids = HashSet::new();
|
||||
for folder in &snapshot.folders {
|
||||
@@ -1073,3 +1058,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn replace_notes_in_txn(
|
||||
txn: &redb::WriteTransaction,
|
||||
snapshot: &NotesSnapshot,
|
||||
) -> AppResult<()> {
|
||||
validate_notes_snapshot(snapshot)?;
|
||||
clear_prefix_in_txn(txn, NOTE_FOLDERS_TABLE, NOTE_FOLDER_PREFIX)?;
|
||||
clear_prefix_in_txn(txn, NOTES_TABLE, NOTE_DOCUMENT_PREFIX)?;
|
||||
clear_prefix_in_txn(txn, NOTE_SUMMARIES_TABLE, NOTE_SUMMARY_PREFIX)?;
|
||||
for folder in &snapshot.folders {
|
||||
write_note_folder_in_txn(txn, folder)?;
|
||||
}
|
||||
for note in &snapshot.notes {
|
||||
write_note_in_txn(txn, note)?;
|
||||
write_note_summary_in_txn(txn, &NoteSummary::from(note.clone()))?;
|
||||
}
|
||||
write_meta_u32(
|
||||
txn,
|
||||
META_NOTE_SUMMARY_INDEX_VERSION,
|
||||
NOTE_SUMMARY_INDEX_VERSION,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -20,6 +20,39 @@ fn test_storage(name: &str) -> (PathBuf, Storage) {
|
||||
let storage = Storage::open(&dir).expect("open storage");
|
||||
(dir, storage)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_transaction_rolls_back_entities_written_before_a_note_failure() {
|
||||
use crate::core::portable_snapshot::*;
|
||||
let (dir, storage) = test_storage("backup-rollback");
|
||||
let sessions = SessionsConfig {
|
||||
connections: vec![sample_connection("preserved", None, 0)],
|
||||
..Default::default()
|
||||
};
|
||||
storage.replace_sessions(&sessions).unwrap();
|
||||
let settings = crate::config::AppSettings::default();
|
||||
let mut snapshot: PortableSnapshot = serde_json::from_value(serde_json::json!({
|
||||
"schema_version":PORTABLE_SNAPSHOT_SCHEMA_VERSION,"snapshot_kind":"backup",
|
||||
"revision_id":"fixture","device_id":"fixture","created_at_ms":0,"payload_hash":"","app_version":"fixture",
|
||||
"settings":PortableAppSettings::from_app_settings(&settings,&PortableSnapshotKind::Backup),
|
||||
"sessions":{"connections":[]},
|
||||
"notes":{"folders":[{"id":"cycle","name":"bad","parent_id":"cycle","sort_order":0,"created_at_ms":0,"updated_at_ms":0}],"notes":[]}
|
||||
})).unwrap();
|
||||
snapshot.payload_hash = calculate_payload_hash(&snapshot).unwrap();
|
||||
assert!(storage.restore_backup(&snapshot, &settings).is_err());
|
||||
assert_eq!(
|
||||
storage.load_sessions().unwrap().connections[0].id,
|
||||
"preserved"
|
||||
);
|
||||
drop(storage);
|
||||
let reopened = Storage::open(&dir).unwrap();
|
||||
assert_eq!(
|
||||
reopened.load_sessions().unwrap().connections[0].id,
|
||||
"preserved"
|
||||
);
|
||||
drop(reopened);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
fn sample_group(id: &str, sort_order: i32) -> Group {
|
||||
Group {
|
||||
id: id.to_string(),
|
||||
|
||||
@@ -182,7 +182,7 @@ fn unwrap_master_key_with_compatible_wrapping(
|
||||
}
|
||||
|
||||
/// Loads the master key from redb `master.key`, creating it on first use.
|
||||
fn get_master_key() -> AppResult<Key<Aes256Gcm>> {
|
||||
pub(crate) fn get_master_key() -> AppResult<Key<Aes256Gcm>> {
|
||||
if let Some(encoded) = crate::storage::load_master_key_token()? {
|
||||
let raw = B64
|
||||
.decode(encoded.trim())
|
||||
@@ -204,6 +204,54 @@ fn get_master_key() -> AppResult<Key<Aes256Gcm>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Portable backups wrap a key with an explicit password without changing runtime keys.
|
||||
pub(crate) fn backup_key_token(key: &Key<Aes256Gcm>, password: &str) -> AppResult<String> {
|
||||
let cipher = Aes256Gcm::new(&derive_wrapping_key(Some(password))?);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let mut bytes = nonce.to_vec();
|
||||
bytes.extend(
|
||||
cipher
|
||||
.encrypt(&nonce, key.as_slice())
|
||||
.map_err(|_| AppError::Crypto("Backup key wrapping failed".into()))?,
|
||||
);
|
||||
Ok(B64.encode(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn backup_key(token: &str, password: &str) -> AppResult<Key<Aes256Gcm>> {
|
||||
let bytes = B64
|
||||
.decode(token)
|
||||
.map_err(|_| AppError::Crypto("Invalid backup key".into()))?;
|
||||
Ok(unwrap_master_key_with_compatible_wrapping(&bytes, Some(password))?.0)
|
||||
}
|
||||
|
||||
pub(crate) fn transcode_secret(
|
||||
token: &str,
|
||||
source: &Key<Aes256Gcm>,
|
||||
target: &Key<Aes256Gcm>,
|
||||
) -> AppResult<String> {
|
||||
let bytes = B64
|
||||
.decode(token)
|
||||
.map_err(|_| AppError::Crypto("Invalid backup credential".into()))?;
|
||||
if bytes.len() < 28 {
|
||||
return Err(AppError::Crypto("Invalid backup credential".into()));
|
||||
}
|
||||
let plain = zeroize::Zeroizing::new(
|
||||
Aes256Gcm::new(source)
|
||||
.decrypt(aes_gcm::Nonce::from_slice(&bytes[..12]), &bytes[12..])
|
||||
.map_err(|_| AppError::Crypto("Backup credential decryption failed".into()))?,
|
||||
);
|
||||
std::str::from_utf8(&plain)
|
||||
.map_err(|_| AppError::Crypto("Invalid backup credential encoding".into()))?;
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let mut encrypted = nonce.to_vec();
|
||||
encrypted.extend(
|
||||
Aes256Gcm::new(target)
|
||||
.encrypt(&nonce, plain.as_slice())
|
||||
.map_err(|_| AppError::Crypto("Backup credential encryption failed".into()))?,
|
||||
);
|
||||
Ok(B64.encode(encrypted))
|
||||
}
|
||||
|
||||
pub fn verify_master_key_token() -> AppResult<()> {
|
||||
let _ = get_master_key()?;
|
||||
Ok(())
|
||||
|
||||
Generated
+138
-13
@@ -277,7 +277,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
|
||||
dependencies = [
|
||||
"blowfish",
|
||||
"pbkdf2",
|
||||
"pbkdf2 0.13.0",
|
||||
"sha2 0.11.0",
|
||||
]
|
||||
|
||||
@@ -324,6 +324,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
@@ -391,6 +400,15 @@ dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
@@ -2018,6 +2036,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
@@ -2027,7 +2046,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"block-padding 0.4.2",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
@@ -2130,6 +2149,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.2.2"
|
||||
@@ -2314,7 +2342,7 @@ dependencies = [
|
||||
"module-lattice",
|
||||
"pkcs8 0.11.0",
|
||||
"rand_core 0.10.1",
|
||||
"sha3",
|
||||
"sha3 0.11.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2400,6 +2428,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nucleo-matcher"
|
||||
version = "0.3.1"
|
||||
@@ -2475,9 +2512,11 @@ dependencies = [
|
||||
name = "nyaterm-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"aes-gcm 0.10.3",
|
||||
"async-http-proxy",
|
||||
"base64 0.22.1",
|
||||
"cbc 0.1.2",
|
||||
"dirs",
|
||||
"encoding_rs",
|
||||
"font-kit",
|
||||
@@ -2488,6 +2527,8 @@ dependencies = [
|
||||
"md-5",
|
||||
"nucleo-matcher",
|
||||
"nyaterm-gpu",
|
||||
"pbkdf2 0.12.2",
|
||||
"quick-xml",
|
||||
"rand 0.8.8",
|
||||
"redb",
|
||||
"regex",
|
||||
@@ -2498,6 +2539,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha1 0.10.7",
|
||||
"sha2 0.10.9",
|
||||
"sha3 0.10.9",
|
||||
"ssh-key 0.6.7",
|
||||
"strip-ansi-escapes",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2544,14 +2586,18 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"subtle",
|
||||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2769,6 +2815,16 @@ dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.13.0"
|
||||
@@ -2848,9 +2904,9 @@ checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"aes-gcm 0.11.1",
|
||||
"cbc",
|
||||
"cbc 0.2.1",
|
||||
"der 0.8.2",
|
||||
"pbkdf2",
|
||||
"pbkdf2 0.13.0",
|
||||
"rand_core 0.10.1",
|
||||
"scrypt",
|
||||
"sha2 0.11.0",
|
||||
@@ -3016,6 +3072,15 @@ version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -3313,10 +3378,10 @@ version = "0.62.1"
|
||||
dependencies = [
|
||||
"aes 0.9.3",
|
||||
"bitflags 2.13.2",
|
||||
"block-padding",
|
||||
"block-padding 0.4.2",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"cbc",
|
||||
"cbc 0.2.1",
|
||||
"cipher 0.5.2",
|
||||
"crypto-bigint 0.7.5",
|
||||
"ctr 0.10.1",
|
||||
@@ -3339,7 +3404,7 @@ dependencies = [
|
||||
"hmac 0.13.0",
|
||||
"inout 0.2.2",
|
||||
"internal-russh-num-bigint",
|
||||
"keccak",
|
||||
"keccak 0.2.2",
|
||||
"log",
|
||||
"md5",
|
||||
"ml-kem",
|
||||
@@ -3349,7 +3414,7 @@ dependencies = [
|
||||
"p384 0.14.0-rc.15",
|
||||
"p521 0.14.0-rc.15",
|
||||
"pageant",
|
||||
"pbkdf2",
|
||||
"pbkdf2 0.13.0",
|
||||
"pkcs1 0.8.0-rc.4",
|
||||
"pkcs5",
|
||||
"pkcs8 0.11.0",
|
||||
@@ -3365,7 +3430,7 @@ dependencies = [
|
||||
"sec1 0.8.1",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha3",
|
||||
"sha3 0.11.0",
|
||||
"signature 3.0.0",
|
||||
"spki 0.8.0",
|
||||
"ssh-encoding 0.3.0",
|
||||
@@ -3549,7 +3614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"pbkdf2",
|
||||
"pbkdf2 0.13.0",
|
||||
"salsa20",
|
||||
"sha2 0.11.0",
|
||||
]
|
||||
@@ -3774,6 +3839,16 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"keccak 0.1.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.11.0"
|
||||
@@ -3781,7 +3856,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
"keccak",
|
||||
"keccak 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4146,6 +4230,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.55"
|
||||
@@ -4378,6 +4471,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"nu-ansi-term",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4499,6 +4618,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "value-ext"
|
||||
version = "0.1.5"
|
||||
@@ -5063,7 +5188,7 @@ dependencies = [
|
||||
"indexmap 2.14.2",
|
||||
"lzma-rust2",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"pbkdf2 0.13.0",
|
||||
"ppmd-rust",
|
||||
"sha1 0.11.0",
|
||||
"time",
|
||||
|
||||
@@ -26,6 +26,11 @@ russh = { path = "../../vendor/russh/russh", default-features = false, features
|
||||
russh-sftp = { path = "../../vendor/russh-sftp" }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "native-tls"] }
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["registry"] }
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
zip = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -238,7 +238,7 @@ async fn start(state: &Arc<State>, owner: &str, args: &Value) -> Result<Value> {
|
||||
let events = login.events.clone();
|
||||
let task_stream = stream_id.clone();
|
||||
let task_session = session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
let sink = stream::EventSink(move |id: &str, payload: AiStreamEventPayload| {
|
||||
let _ = events.send(Event {
|
||||
event: format!("ai-stream-{id}"),
|
||||
|
||||
@@ -42,6 +42,11 @@ pub async fn verify_unlock(state: &State, args: &serde_json::Value) -> Result<bo
|
||||
let mut attempts = state.login_attempts.lock().await;
|
||||
attempts.retain(|instant| instant.elapsed() < Duration::from_secs(60));
|
||||
if attempts.len() >= 20 {
|
||||
tracing::warn!(
|
||||
event = "auth.rate_limited",
|
||||
reason = "attempt_budget",
|
||||
"Web sign-in rate limited"
|
||||
);
|
||||
return Err(WebError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Try again later".into(),
|
||||
@@ -153,6 +158,11 @@ pub async fn sign_in(
|
||||
let mut attempts = state.login_attempts.lock().await;
|
||||
attempts.retain(|instant| instant.elapsed() < Duration::from_secs(60));
|
||||
if attempts.len() >= 20 {
|
||||
tracing::warn!(
|
||||
event = "auth.rate_limited",
|
||||
reason = "attempt_budget",
|
||||
"Web sign-in rate limited"
|
||||
);
|
||||
return Err(WebError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Try again later".into(),
|
||||
@@ -163,6 +173,11 @@ pub async fn sign_in(
|
||||
let valid = equal(&digest(&payload.password), &state.password_hash);
|
||||
payload.password.zeroize();
|
||||
if !valid {
|
||||
tracing::warn!(
|
||||
event = "auth.rejected",
|
||||
reason = "invalid_credentials",
|
||||
"Web sign-in rejected"
|
||||
);
|
||||
return Err(WebError(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid credentials".into(),
|
||||
@@ -191,6 +206,7 @@ pub async fn sign_in(
|
||||
cancel: state.shutdown.child_token(),
|
||||
}),
|
||||
);
|
||||
tracing::info!(event = "auth.accepted", "Web sign-in accepted");
|
||||
Ok((
|
||||
[(header::SET_COOKIE, cookie(&state, &owner, 8 * 3600))],
|
||||
Json(json!({"csrf":csrf})),
|
||||
@@ -215,6 +231,7 @@ pub async fn sign_out(
|
||||
axum::Extension(owner): axum::Extension<Owner>,
|
||||
) -> Response {
|
||||
state.close_owner(&owner.0).await;
|
||||
tracing::info!(event = "auth.signed_out", "Web administrator signed out");
|
||||
(
|
||||
[(header::SET_COOKIE, cookie(&state, "", 0))],
|
||||
Json(json!({})),
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
use crate::{
|
||||
auth::Owner,
|
||||
error::{Result, WebError},
|
||||
state::State,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{Multipart, State as ExtractState},
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use nyaterm_core::{core::backup, storage};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
#[derive(Deserialize)]
|
||||
pub struct ExportRequest {
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn export(
|
||||
ExtractState(state): ExtractState<Arc<State>>,
|
||||
Extension(_owner): Extension<Owner>,
|
||||
Json(request): Json<ExportRequest>,
|
||||
) -> Result<Response> {
|
||||
let _guard = state.mutation.lock().await;
|
||||
let password = zeroize::Zeroizing::new(request.password);
|
||||
let bytes =
|
||||
crate::observability::blocking(move || backup::export_backup(&password, APP_VERSION))
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Backup operation failed"))??;
|
||||
tracing::info!(
|
||||
event = "backup.export",
|
||||
bytes = bytes.len(),
|
||||
"Configuration backup exported"
|
||||
);
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/octet-stream"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"nyaterm-backup.nya\"",
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn uploaded(
|
||||
mut multipart: Multipart,
|
||||
limit: usize,
|
||||
) -> Result<(zeroize::Zeroizing<String>, zeroize::Zeroizing<Vec<u8>>)> {
|
||||
let mut password = None;
|
||||
let mut bytes = None;
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Invalid upload"))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_owned();
|
||||
let max = if name == "file" { limit } else { 1024 };
|
||||
let mut value = zeroize::Zeroizing::new(Vec::new());
|
||||
while let Some(chunk) = field
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Invalid upload"))?
|
||||
{
|
||||
if value.len().saturating_add(chunk.len()) > max {
|
||||
return Err(WebError(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
"Upload exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
value.extend_from_slice(&chunk);
|
||||
}
|
||||
match name.as_str() {
|
||||
"password" | "source" if password.is_none() => {
|
||||
password = Some(zeroize::Zeroizing::new(
|
||||
String::from_utf8(value.to_vec())
|
||||
.map_err(|_| WebError::bad("Invalid upload field"))?,
|
||||
))
|
||||
}
|
||||
"file" if bytes.is_none() => bytes = Some(value),
|
||||
_ => return Err(WebError::bad("Unexpected or duplicate upload field")),
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
password.ok_or(WebError::bad("Missing upload field"))?,
|
||||
bytes.ok_or(WebError::bad("Missing file"))?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn import(
|
||||
ExtractState(state): ExtractState<Arc<State>>,
|
||||
Extension(_owner): Extension<Owner>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Value>> {
|
||||
let (password, bytes) = uploaded(multipart, backup::MAX_BACKUP_BYTES).await?;
|
||||
let snapshot =
|
||||
crate::observability::blocking(move || backup::prepare_import(&bytes, &password))
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Backup operation failed"))?
|
||||
.map_err(import_error)?;
|
||||
let _guard = state.mutation.lock().await;
|
||||
crate::observability::blocking(move || {
|
||||
let settings = backup::settings_for_import(&snapshot)?;
|
||||
storage::backup::restore_backup(&snapshot, &settings)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Backup operation failed"))?
|
||||
.map_err(import_error)?;
|
||||
crate::observability::reload_settings();
|
||||
for event in [
|
||||
"connections-changed",
|
||||
"settings-changed",
|
||||
"quick-commands-changed",
|
||||
"command-history-changed",
|
||||
"proxy-saved",
|
||||
"credentials-changed",
|
||||
] {
|
||||
state.broadcast(event, Value::Null).await;
|
||||
}
|
||||
state
|
||||
.broadcast(
|
||||
"notes-changed",
|
||||
json!({"kind":"replaced","ids":[],"folders":[],"notes":[],"tree_changed":true}),
|
||||
)
|
||||
.await;
|
||||
tracing::info!(event = "backup.import", "Configuration backup restored");
|
||||
Ok(Json(json!({"status":"completed"})))
|
||||
}
|
||||
|
||||
pub async fn connections(
|
||||
ExtractState(state): ExtractState<Arc<State>>,
|
||||
Extension(_owner): Extension<Owner>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Value>> {
|
||||
let (source, bytes) = uploaded(multipart, 10 * 1024 * 1024).await?;
|
||||
let _guard = state.mutation.lock().await;
|
||||
let result = crate::observability::blocking(move || nyaterm_core::core::importer::import_browser_connections(&source, &bytes)).await.map_err(|_| WebError::bad("Import operation failed"))?.map_err(|error| {
|
||||
// Only a fixed message may cross the API boundary; parser errors can contain imported secrets.
|
||||
crate::observability::record_error(&error);
|
||||
tracing::warn!(event="connections.import_failed", "Connection import failed");
|
||||
if matches!(&error, nyaterm_core::error::AppError::Config(message) if message.starts_with("WindTerm profile")) {
|
||||
WebError::bad("WindTerm profile resources required; import on Desktop and transfer a .nya backup")
|
||||
} else { WebError::bad("Invalid or unsupported connection import") }
|
||||
})?;
|
||||
state.broadcast("connections-changed", Value::Null).await;
|
||||
tracing::info!(
|
||||
event = "connections.import",
|
||||
count = result.imported,
|
||||
"Connections imported"
|
||||
);
|
||||
Ok(Json(serde_json::to_value(result)?))
|
||||
}
|
||||
|
||||
fn import_error(error: nyaterm_core::error::AppError) -> WebError {
|
||||
crate::observability::record_error(&error);
|
||||
tracing::warn!(
|
||||
event = "backup.import_failed",
|
||||
"Backup password, integrity or data validation failed"
|
||||
);
|
||||
WebError::bad("Invalid backup, incorrect password, or unsupported backup data")
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use axum::{
|
||||
use nyaterm_core::{config, services, storage, utils::crypto};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use tracing::Instrument;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SortOrderUpdate {
|
||||
@@ -31,8 +32,21 @@ pub async fn route(
|
||||
Path(command): Path<String>,
|
||||
Json(args): Json<Value>,
|
||||
) -> Result<Json<Value>> {
|
||||
let span = tracing::info_span!("web.command", operation=%command, session_id=args["sessionId"].as_str());
|
||||
async move {
|
||||
// Explicit allowlist; no dynamic reflection or Desktop invoke handler here.
|
||||
let result = match command.as_str() {
|
||||
"import_keyword_highlight_rules" => {
|
||||
let _guard = state.mutation.lock().await;
|
||||
let content = text(&args, "content")?;
|
||||
if content.len() > 1024 * 1024 { return Err(WebError::bad("Import exceeds 1 MiB")); }
|
||||
let rules = nyaterm_core::core::keyword_highlights::parse_keyword_highlight_import(content)?;
|
||||
let result = storage::update_settings_doc::<config::AppSettings,_,_>(storage::SettingsDocKey::AppSettings, |settings| {
|
||||
nyaterm_core::core::keyword_highlights::merge_keyword_highlight_rules(&mut settings.terminal.keyword_highlights, rules, || uuid::Uuid::new_v4().to_string())
|
||||
})?;
|
||||
state.broadcast("settings-changed", Value::Null).await;
|
||||
json!(result)
|
||||
},
|
||||
"get_app_runtime_info" => json!({"mode":"web","portable":false,"packageManager":null,"executableDir":"","dataDir":"","configDir":"","logDir":"","webviewDataDir":"","portableMarkerPath":null}),
|
||||
"get_support_info" => json!({"os":format!("Web server ({})",std::env::consts::OS),"architecture":std::env::consts::ARCH,"runtime":"web","packageManager":null}),
|
||||
"get_app_settings" => {
|
||||
@@ -57,6 +71,7 @@ pub async fn route(
|
||||
next.ai=config::encrypt_ai_settings(config::merge_masked_ai_settings(¤t.ai,next.ai))?;
|
||||
next.cloud_sync=config::encrypt_cloud_sync_settings(config::merge_masked_cloud_sync_settings(¤t.cloud_sync,next.cloud_sync))?;
|
||||
config::save_app_settings(&(),&next)?;
|
||||
crate::observability::configure(&next.diagnostics);
|
||||
state.broadcast("settings-changed",Value::Null).await;
|
||||
Value::Null
|
||||
},
|
||||
@@ -263,5 +278,7 @@ pub async fn route(
|
||||
command if crate::ai::supports(command) => crate::ai::command(&state,&owner.0,command,&args).await?,
|
||||
_ => return Err(WebError::unsupported()),
|
||||
};
|
||||
tracing::info!(event="command.completed", status=200, "Web command completed");
|
||||
Ok(Json(result))
|
||||
}.instrument(span).await
|
||||
}
|
||||
@@ -12,6 +12,10 @@ impl WebError {
|
||||
pub fn bad(message: impl Into<String>) -> Self {
|
||||
Self(StatusCode::BAD_REQUEST, message.into())
|
||||
}
|
||||
pub fn io(error: &std::io::Error, message: &'static str) -> Self {
|
||||
tracing::warn!(event="io.error", error_kind="io", error_code=error.raw_os_error(), reason=?error.kind(), error_hash=%crate::observability::fingerprint(&error.to_string()), "I/O operation failed");
|
||||
Self::bad(message)
|
||||
}
|
||||
pub fn forbidden() -> Self {
|
||||
Self(StatusCode::FORBIDDEN, "Access denied".into())
|
||||
}
|
||||
@@ -23,7 +27,8 @@ impl WebError {
|
||||
}
|
||||
}
|
||||
impl From<nyaterm_core::error::AppError> for WebError {
|
||||
fn from(_: nyaterm_core::error::AppError) -> Self {
|
||||
fn from(error: nyaterm_core::error::AppError) -> Self {
|
||||
crate::observability::record_error(&error);
|
||||
Self::bad("Backend operation failed")
|
||||
}
|
||||
}
|
||||
@@ -33,17 +38,20 @@ impl From<serde_json::Error> for WebError {
|
||||
}
|
||||
}
|
||||
impl From<russh::Error> for WebError {
|
||||
fn from(_: russh::Error) -> Self {
|
||||
fn from(error: russh::Error) -> Self {
|
||||
tracing::warn!(event="ssh.error", error_kind="ssh", reason=crate::observability::error_reason(&error.to_string()), error_hash=%crate::observability::fingerprint(&error.to_string()), "SSH operation failed");
|
||||
Self::bad("SSH operation failed")
|
||||
}
|
||||
}
|
||||
impl From<russh_sftp::client::error::Error> for WebError {
|
||||
fn from(_: russh_sftp::client::error::Error) -> Self {
|
||||
fn from(error: russh_sftp::client::error::Error) -> Self {
|
||||
crate::observability::record_error(&nyaterm_core::error::AppError::Sftp(error));
|
||||
Self::bad("SFTP operation failed")
|
||||
}
|
||||
}
|
||||
impl IntoResponse for WebError {
|
||||
fn into_response(self) -> Response {
|
||||
tracing::warn!(event="api.error_response", status=self.0.as_u16(), reason=crate::observability::error_reason(&self.1), error_hash=%crate::observability::fingerprint(&self.1), "API operation rejected");
|
||||
(self.0, Json(json!({"error": self.1}))).into_response()
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
pub mod ai;
|
||||
pub mod auth;
|
||||
pub mod backups;
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod monitoring;
|
||||
pub mod network;
|
||||
pub mod notes;
|
||||
pub mod observability;
|
||||
pub mod otp;
|
||||
pub mod plugins;
|
||||
pub mod remote_exec;
|
||||
@@ -39,7 +41,10 @@ async fn events(
|
||||
) -> error::Result<Sse<impl futures_util::Stream<Item = std::result::Result<Event, Infallible>>>> {
|
||||
let login = state.login(&owner.0).await?;
|
||||
let mut receiver = login.events.subscribe();
|
||||
tracing::info!(event = "sse.connected", "Web event stream connected");
|
||||
let guard = observability::StreamLogGuard::new("sse.disconnected", None);
|
||||
let stream = async_stream::stream! {
|
||||
let _guard = guard;
|
||||
yield Ok(Event::default().data(r#"{"event":"ready","payload":null}"#));
|
||||
loop{
|
||||
tokio::select!{
|
||||
@@ -58,6 +63,20 @@ pub fn router(state: Arc<State>, dist: std::path::PathBuf) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/auth/session", get(auth::current))
|
||||
.route("/auth/logout", post(auth::sign_out))
|
||||
.route("/backups/export", post(backups::export))
|
||||
.route(
|
||||
"/backups/import",
|
||||
post(backups::import).layer(DefaultBodyLimit::max(50 * 1024 * 1024 + 16 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/imports/connections",
|
||||
post(backups::connections).layer(DefaultBodyLimit::max(10 * 1024 * 1024 + 16 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/logs/frontend",
|
||||
post(observability::frontend).layer(DefaultBodyLimit::max(256 * 1024)),
|
||||
)
|
||||
.route("/diagnostics/export", get(observability::diagnostics))
|
||||
.route("/events", get(events))
|
||||
.route("/sessions", post(session::create_route))
|
||||
.route("/sessions/{id}/terminal", get(session::ws_route))
|
||||
@@ -101,6 +120,7 @@ pub fn router(state: Arc<State>, dist: std::path::PathBuf) -> Router {
|
||||
.layer(SetResponseHeaderLayer::overriding(axum::http::header::CACHE_CONTROL,HeaderValue::from_static("no-store")))
|
||||
.layer(SetResponseHeaderLayer::overriding(axum::http::header::X_CONTENT_TYPE_OPTIONS,HeaderValue::from_static("nosniff")))
|
||||
.layer(SetResponseHeaderLayer::overriding(axum::http::header::REFERRER_POLICY,HeaderValue::from_static("same-origin")))
|
||||
.layer(middleware::from_fn(observability::request_log))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.unwrap_or_else(|_| "false".into())
|
||||
.parse()
|
||||
.unwrap_or(false);
|
||||
if url.scheme() != "https"
|
||||
&& !loopback
|
||||
&& !allow_insecure_http
|
||||
{
|
||||
if url.scheme() != "https" && !loopback && !allow_insecure_http {
|
||||
return Err("Non-loopback public URLs require HTTPS".into());
|
||||
}
|
||||
let base_path = url.path().trim_end_matches('/').to_owned();
|
||||
@@ -62,6 +59,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let data = PathBuf::from(
|
||||
std::env::var("NYATERM_WEB_DATA_DIR").unwrap_or_else(|_| "./nyaterm-web-data".into()),
|
||||
);
|
||||
nyaterm_web::observability::init(&data);
|
||||
std::fs::create_dir_all(&data)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -69,6 +67,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
nyaterm_core::storage::init(&data)?;
|
||||
nyaterm_web::observability::reload_settings();
|
||||
// Existing encrypted master-password data is not silently discarded. Web
|
||||
// unwraps it with the external server key, preserving the existing key hierarchy.
|
||||
if let Some(secret) = nyaterm_core::storage::load_settings_doc::<
|
||||
@@ -100,12 +99,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
mutation: Mutex::new(()),
|
||||
shutdown: tokio_util::sync::CancellationToken::new(),
|
||||
});
|
||||
tokio::spawn(nyaterm_web::reap(state.clone()));
|
||||
nyaterm_web::observability::spawn(nyaterm_web::reap(state.clone()));
|
||||
let bind = std::env::var("NYATERM_WEB_BIND").unwrap_or_else(|_| "127.0.0.1:8080".into());
|
||||
let listener = tokio::net::TcpListener::bind(&bind).await?;
|
||||
eprintln!("NyaTerm Web listening on {bind}");
|
||||
tracing::info!(event = "server.started", "NyaTerm Web server started");
|
||||
let shutdown = state.shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
nyaterm_web::observability::spawn(async move {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut terminate =
|
||||
@@ -138,5 +137,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
})
|
||||
.await;
|
||||
tracing::info!(event = "server.stopped", "NyaTerm Web server stopped");
|
||||
nyaterm_web::observability::shutdown();
|
||||
Ok(())
|
||||
}
|
||||
@@ -58,14 +58,20 @@ fn open_inner(
|
||||
match resolved.kind {
|
||||
TransportRouteKind::Direct => Ok(network::open_direct_transport(&host, port)
|
||||
.await
|
||||
.map_err(|e| WebError::bad(e.to_string()))?),
|
||||
.map_err(|error| {
|
||||
crate::observability::record_error(&error);
|
||||
WebError::bad(error.to_string())
|
||||
})?),
|
||||
TransportRouteKind::Proxy => {
|
||||
let proxy = network::resolve_proxy(&(), resolved.proxy_id.as_deref().unwrap())
|
||||
.map_err(|e| match e {
|
||||
nyaterm_core::error::AppError::Crypto(_) => {
|
||||
WebError::bad("Proxy credentials could not be decrypted")
|
||||
.map_err(|e| {
|
||||
crate::observability::record_error(&e);
|
||||
match e {
|
||||
nyaterm_core::error::AppError::Crypto(_) => {
|
||||
WebError::bad("Proxy credentials could not be decrypted")
|
||||
}
|
||||
_ => WebError::bad("Proxy configuration is missing or invalid"),
|
||||
}
|
||||
_ => WebError::bad("Proxy configuration is missing or invalid"),
|
||||
})?;
|
||||
if !matches!(proxy.protocol.as_str(), "socks5" | "http") {
|
||||
return Err(WebError::bad(
|
||||
@@ -75,7 +81,10 @@ fn open_inner(
|
||||
validate_target(&proxy.host, proxy.port)?;
|
||||
Ok(network::open_proxy_transport(proxy, &host, port)
|
||||
.await
|
||||
.map_err(|e| WebError::bad(e.to_string()))?)
|
||||
.map_err(|error| {
|
||||
crate::observability::record_error(&error);
|
||||
WebError::bad(error.to_string())
|
||||
})?)
|
||||
}
|
||||
TransportRouteKind::SshJump => {
|
||||
let id = resolved.proxy_jump_id.unwrap();
|
||||
|
||||
@@ -0,0 +1,776 @@
|
||||
//! Bounded, structured deployment logs. Unstructured data is hashed at the sink.
|
||||
use crate::{
|
||||
auth::Owner,
|
||||
error::{Result, WebError},
|
||||
state::State,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::{MatchedPath, Request, State as ExtractState},
|
||||
http::HeaderValue,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use nyaterm_core::{
|
||||
config::{self, DiagnosticsSettings},
|
||||
error::AppError,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{Cursor, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex, OnceLock,
|
||||
atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tracing::{
|
||||
Event, Instrument, Subscriber,
|
||||
field::{Field, Visit},
|
||||
};
|
||||
use tracing_subscriber::{
|
||||
Layer,
|
||||
layer::{Context, SubscriberExt},
|
||||
registry::LookupSpan,
|
||||
};
|
||||
|
||||
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
||||
const TOTAL_BYTES: u64 = 100 * 1024 * 1024;
|
||||
static LOGS: OnceLock<Arc<Logs>> = OnceLock::new();
|
||||
enum Job {
|
||||
Entry(Vec<u8>),
|
||||
Flush(mpsc::Sender<()>),
|
||||
Shutdown(mpsc::Sender<()>),
|
||||
}
|
||||
struct Logs {
|
||||
sender: mpsc::SyncSender<Job>,
|
||||
dir: PathBuf,
|
||||
level: AtomicU8,
|
||||
retention: AtomicU32,
|
||||
dropped: AtomicU64,
|
||||
rates: Mutex<HashMap<String, (Instant, u32)>>,
|
||||
}
|
||||
|
||||
pub fn fingerprint(text: &str) -> String {
|
||||
format!("{:x}", Sha256::digest(text.as_bytes()))[..16].into()
|
||||
}
|
||||
fn level_number(level: &tracing::Level) -> u8 {
|
||||
match *level {
|
||||
tracing::Level::ERROR => 3,
|
||||
tracing::Level::WARN => 2,
|
||||
tracing::Level::INFO => 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Fields(Map<String, Value>);
|
||||
impl Visit for Fields {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.0.insert(field.name().into(), json!(value));
|
||||
}
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
self.0.insert(field.name().into(), json!(value));
|
||||
}
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
self.0.insert(field.name().into(), json!(value));
|
||||
}
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
self.0.insert(field.name().into(), json!(value));
|
||||
}
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.record_str(field, &format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
struct LogLayer(Arc<Logs>);
|
||||
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for LogLayer {
|
||||
fn on_new_span(
|
||||
&self,
|
||||
attrs: &tracing::span::Attributes<'_>,
|
||||
id: &tracing::Id,
|
||||
ctx: Context<'_, S>,
|
||||
) {
|
||||
let mut fields = Fields::default();
|
||||
attrs.record(&mut fields);
|
||||
if let Some(span) = ctx.span(id) {
|
||||
span.extensions_mut().insert(fields);
|
||||
}
|
||||
}
|
||||
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
|
||||
if level_number(event.metadata().level()) < self.0.level.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut fields = Fields::default();
|
||||
if let Some(scope) = ctx.event_scope(event) {
|
||||
for span in scope.from_root() {
|
||||
if let Some(values) = span.extensions().get::<Fields>() {
|
||||
fields.0.extend(values.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
event.record(&mut fields);
|
||||
let entry = json!({
|
||||
"timestamp":time::OffsetDateTime::now_utc().format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
|
||||
"level":event.metadata().level().as_str().to_lowercase(),
|
||||
"target":event.metadata().target(),
|
||||
"fields":sanitize_fields(fields.0),
|
||||
});
|
||||
self.0.enqueue(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// An allowlist avoids leaking arbitrary error strings, command arguments or imported content.
|
||||
fn sanitize_fields(fields: Map<String, Value>) -> Map<String, Value> {
|
||||
fields
|
||||
.into_iter()
|
||||
.map(|(key, value)| {
|
||||
let safe = match key.as_str() {
|
||||
"request_id" | "session_id" | "connection_id" | "transfer_id" => value
|
||||
.as_str()
|
||||
.filter(|s| uuid::Uuid::parse_str(s).is_ok())
|
||||
.map(|s| json!(s)),
|
||||
"event" | "domain" | "operation" | "error_kind" | "reason" | "method" => value
|
||||
.as_str()
|
||||
.filter(|s| {
|
||||
s.len() <= 96
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "._:- ".contains(c))
|
||||
})
|
||||
.map(|s| json!(s)),
|
||||
"error_hash" => value
|
||||
.as_str()
|
||||
.filter(|s| s.len() == 16 && s.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.map(|s| json!(s)),
|
||||
"route" => value
|
||||
.as_str()
|
||||
.filter(|s| s.len() <= 160 && s.starts_with('/') && !s.contains('?'))
|
||||
.map(|s| json!(s)),
|
||||
"status" | "duration_ms" | "bytes" | "count" | "error_code" | "dropped_count"
|
||||
| "limit" => {
|
||||
if value.is_number() {
|
||||
Some(value.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
// Frontend data was validated and sanitized independently below.
|
||||
"frontend" => value
|
||||
.as_str()
|
||||
.and_then(|s| serde_json::from_str::<Value>(s).ok())
|
||||
.map(|v| sanitize_frontend(&v, 0)),
|
||||
_ => None,
|
||||
};
|
||||
(
|
||||
key,
|
||||
safe.unwrap_or_else(|| json!({"hash":fingerprint(&value.to_string())})),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl Logs {
|
||||
fn enqueue(&self, value: Value) {
|
||||
if let Ok(mut bytes) = serde_json::to_vec(&value) {
|
||||
bytes.push(b'\n');
|
||||
if self.sender.try_send(Job::Entry(bytes)).is_err() {
|
||||
self.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(data_dir: &Path) {
|
||||
let (sender, receiver) = mpsc::sync_channel(2048);
|
||||
let logs = Arc::new(Logs {
|
||||
sender,
|
||||
dir: data_dir.join("logs"),
|
||||
level: AtomicU8::new(1),
|
||||
retention: AtomicU32::new(7),
|
||||
dropped: AtomicU64::new(0),
|
||||
rates: Mutex::new(HashMap::new()),
|
||||
});
|
||||
if LOGS.set(logs.clone()).is_err() {
|
||||
return;
|
||||
}
|
||||
let worker = logs.clone();
|
||||
std::thread::spawn(move || run_writer(worker, receiver, std::io::stdout()));
|
||||
let _ = tracing::subscriber::set_global_default(
|
||||
tracing_subscriber::registry().with(LogLayer(logs)),
|
||||
);
|
||||
}
|
||||
pub fn reload_settings() {
|
||||
if let Ok(settings) = config::load_app_settings(&()) {
|
||||
configure(&settings.diagnostics);
|
||||
}
|
||||
}
|
||||
pub fn configure(settings: &DiagnosticsSettings) {
|
||||
if let Some(logs) = LOGS.get() {
|
||||
logs.level.store(
|
||||
match settings.level {
|
||||
config::DiagnosticsLogLevel::Debug => 0,
|
||||
config::DiagnosticsLogLevel::Info => 1,
|
||||
config::DiagnosticsLogLevel::Warn => 2,
|
||||
},
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
logs.retention
|
||||
.store(settings.retention_days.clamp(1, 30), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub fn flush() {
|
||||
if let Some(logs) = LOGS.get() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
if logs.sender.try_send(Job::Flush(tx)).is_ok() {
|
||||
let _ = rx.recv_timeout(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn shutdown() {
|
||||
if let Some(logs) = LOGS.get() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
if logs.sender.try_send(Job::Shutdown(tx)).is_ok() {
|
||||
let _ = rx.recv_timeout(Duration::from_secs(3));
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn log_files(dir: &Path) -> Vec<(PathBuf, u64, SystemTime)> {
|
||||
let mut files = fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str()?;
|
||||
if !name.starts_with("nyaterm-web-") || !name.ends_with(".jsonl") {
|
||||
return None;
|
||||
}
|
||||
// DirEntry metadata can cache size=0 on Windows while the writer is open.
|
||||
let meta = fs::metadata(entry.path()).ok()?;
|
||||
if !meta.is_file() || entry.file_type().ok()?.is_symlink() {
|
||||
return None;
|
||||
}
|
||||
Some((entry.path(), meta.len(), meta.modified().ok()?))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
files.sort_by_key(|file| file.2);
|
||||
files
|
||||
}
|
||||
fn prune(logs: &Logs, current: Option<&Path>) {
|
||||
let files = log_files(&logs.dir);
|
||||
let mut total: u64 = files.iter().map(|f| f.1).sum();
|
||||
let retention = Duration::from_secs(logs.retention.load(Ordering::Relaxed) as u64 * 86400);
|
||||
for (path, size, modified) in files {
|
||||
if Some(path.as_path()) == current {
|
||||
continue;
|
||||
}
|
||||
if total > TOTAL_BYTES.saturating_sub(if current.is_some() { ROTATE_BYTES } else { 0 })
|
||||
|| modified.elapsed().unwrap_or_default() > retention
|
||||
{
|
||||
if fs::remove_file(path).is_ok() {
|
||||
total = total.saturating_sub(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn run_writer<W: Write>(logs: Arc<Logs>, receiver: mpsc::Receiver<Job>, mut stdout: W) -> W {
|
||||
let mut file: Option<File> = None;
|
||||
let mut current: Option<PathBuf> = None;
|
||||
let mut size = 0u64;
|
||||
let mut day = 0u64;
|
||||
let mut warned = false;
|
||||
loop {
|
||||
match receiver.recv_timeout(Duration::from_secs(30)) {
|
||||
Ok(Job::Shutdown(reply)) => {
|
||||
let _ = stdout.flush();
|
||||
if let Some(file) = &mut file {
|
||||
let _ = file.flush();
|
||||
}
|
||||
drop(file);
|
||||
let _ = reply.send(());
|
||||
break;
|
||||
}
|
||||
Ok(Job::Flush(reply)) => {
|
||||
let _ = stdout.flush();
|
||||
if let Some(file) = &mut file {
|
||||
let _ = file.flush();
|
||||
}
|
||||
let _ = reply.send(());
|
||||
}
|
||||
Ok(Job::Entry(mut bytes)) => {
|
||||
let dropped = logs.dropped.swap(0, Ordering::Relaxed);
|
||||
if dropped > 0 {
|
||||
let mut summary = serde_json::to_vec(&json!({"level":"warn","event":"logger.queue_overflow","dropped_count":dropped})).unwrap_or_default();
|
||||
summary.push(b'\n');
|
||||
summary.extend(bytes);
|
||||
bytes = summary;
|
||||
}
|
||||
let _ = stdout.write_all(&bytes);
|
||||
let today = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
/ 86400;
|
||||
if file.is_none() || size + bytes.len() as u64 > ROTATE_BYTES || day != today {
|
||||
file = None;
|
||||
day = today;
|
||||
size = 0;
|
||||
current = Some(logs.dir.join(format!(
|
||||
"nyaterm-web-{today}-{}.jsonl",
|
||||
uuid::Uuid::new_v4()
|
||||
)));
|
||||
if fs::create_dir_all(&logs.dir).is_ok() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ =
|
||||
fs::set_permissions(&logs.dir, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.create_new(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
file = options.open(current.as_ref().unwrap()).ok();
|
||||
}
|
||||
prune(&logs, current.as_deref());
|
||||
}
|
||||
if file
|
||||
.as_mut()
|
||||
.is_none_or(|file| file.write_all(&bytes).is_err())
|
||||
{
|
||||
file = None;
|
||||
if !warned {
|
||||
let _ = std::io::stderr().write_all(
|
||||
b"NyaTerm Web log file unavailable; stdout logging continues\n",
|
||||
);
|
||||
warned = true;
|
||||
}
|
||||
} else {
|
||||
size += bytes.len() as u64;
|
||||
}
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => prune(&logs, current.as_deref()),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
stdout
|
||||
}
|
||||
|
||||
pub async fn request_log(mut request: Request, next: Next) -> Response {
|
||||
let request_id = request
|
||||
.headers()
|
||||
.get("x-nyaterm-request-id")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| uuid::Uuid::parse_str(s).ok())
|
||||
.unwrap_or_else(uuid::Uuid::new_v4)
|
||||
.to_string();
|
||||
let route = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|p| p.as_str())
|
||||
.unwrap_or("/unmatched")
|
||||
.to_owned();
|
||||
let method = request.method().as_str().to_owned();
|
||||
let operation = if route.ends_with("/commands/{command}") {
|
||||
request.uri().path().rsplit('/').next().unwrap_or("unknown")
|
||||
} else {
|
||||
route.rsplit('/').next().unwrap_or("unknown")
|
||||
}
|
||||
.to_owned();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(RequestId(request_id.clone()));
|
||||
let session_id = request
|
||||
.uri()
|
||||
.path()
|
||||
.split("/api/sessions/")
|
||||
.nth(1)
|
||||
.and_then(|path| path.split('/').next())
|
||||
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||
.map(|id| id.to_string());
|
||||
let started = Instant::now();
|
||||
let span = tracing::info_span!("web.request", request_id=%request_id, route=%route, method=%method, operation=%operation, session_id=session_id.as_deref());
|
||||
async move {
|
||||
let mut response = next.run(request).await;
|
||||
let status = response.status().as_u16();
|
||||
tracing::info!(
|
||||
event = "http.response",
|
||||
status,
|
||||
duration_ms = started.elapsed().as_millis() as u64,
|
||||
"API request completed"
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-nyaterm-request-id",
|
||||
HeaderValue::from_str(&request_id).unwrap(),
|
||||
);
|
||||
if status >= 400
|
||||
&& response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.is_some_and(|v| v.as_bytes().starts_with(b"application/json"))
|
||||
{
|
||||
let (parts, body) = response.into_parts();
|
||||
if let Ok(bytes) = axum::body::to_bytes(body, 64 * 1024).await {
|
||||
if let Ok(mut value) = serde_json::from_slice::<Value>(&bytes) {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("request_id".into(), json!(request_id));
|
||||
}
|
||||
return (parts, Json(value)).into_response();
|
||||
}
|
||||
return Response::from_parts(parts, axum::body::Body::from(bytes));
|
||||
}
|
||||
return Response::from_parts(parts, axum::body::Body::empty());
|
||||
}
|
||||
response
|
||||
}
|
||||
.instrument(span)
|
||||
.await
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct RequestId(pub String);
|
||||
|
||||
pub fn spawn<F>(future: F) -> tokio::task::JoinHandle<F::Output>
|
||||
where
|
||||
F: std::future::Future + Send + 'static,
|
||||
F::Output: Send + 'static,
|
||||
{
|
||||
tokio::spawn(future.in_current_span())
|
||||
}
|
||||
|
||||
pub struct StreamLogGuard {
|
||||
event: &'static str,
|
||||
session_id: Option<String>,
|
||||
span: tracing::Span,
|
||||
}
|
||||
impl StreamLogGuard {
|
||||
pub fn new(event: &'static str, session_id: Option<String>) -> Self {
|
||||
Self {
|
||||
event,
|
||||
session_id,
|
||||
span: tracing::Span::current(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for StreamLogGuard {
|
||||
fn drop(&mut self) {
|
||||
let _entered = self.span.enter();
|
||||
tracing::info!(
|
||||
event = self.event,
|
||||
session_id = self.session_id.as_deref(),
|
||||
"Web stream disconnected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blocking<F, T>(work: F) -> tokio::task::JoinHandle<T>
|
||||
where
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let span = tracing::Span::current();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _entered = span.enter();
|
||||
work()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn error_reason(text: &str) -> &'static str {
|
||||
let text = text.to_ascii_lowercase();
|
||||
if text.contains("timed out") || text.contains("timeout") {
|
||||
"timeout"
|
||||
} else if text.contains("refused") {
|
||||
"connection_refused"
|
||||
} else if text.contains("permission") || text.contains("denied") {
|
||||
"permission_denied"
|
||||
} else if text.contains("authentication") || text.contains("auth failed") {
|
||||
"authentication_failed"
|
||||
} else if text.contains("resolve") || text.contains("dns") {
|
||||
"name_resolution"
|
||||
} else if text.contains("not found") || text.contains("no such") {
|
||||
"not_found"
|
||||
} else if text.contains("disconnect") || text.contains("closed") || text.contains("eof") {
|
||||
"connection_closed"
|
||||
} else if text.contains("cancel") {
|
||||
"cancelled"
|
||||
} else if text.contains("key") || text.contains("certificate") {
|
||||
"key_validation"
|
||||
} else if text.contains("decrypt") || text.contains("integrity") {
|
||||
"decryption_or_integrity"
|
||||
} else if text.contains("invalid") || text.contains("unsupported") {
|
||||
"validation"
|
||||
} else {
|
||||
"operation_failed"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_error(error: &AppError) {
|
||||
let (kind, code, reason) = match error {
|
||||
AppError::Io(error) => ("io", error.raw_os_error(), format!("{:?}", error.kind())),
|
||||
AppError::Ssh(error) => ("ssh", None, error_reason(&error.to_string()).into()),
|
||||
AppError::SshKey(_) => ("ssh_key", None, "key_validation".into()),
|
||||
AppError::Sftp(error) => (
|
||||
"sftp",
|
||||
match error {
|
||||
russh_sftp::client::error::Error::Status(status) => Some(status.status_code as i32),
|
||||
_ => None,
|
||||
},
|
||||
error_reason(&error.to_string()).into(),
|
||||
),
|
||||
AppError::Json(_) => ("json", None, "invalid_parameters".into()),
|
||||
AppError::Crypto(_) => ("crypto", None, "decryption_or_integrity".into()),
|
||||
AppError::Config(_) => ("config", None, "validation".into()),
|
||||
AppError::Storage(_) => ("storage", None, "database_operation".into()),
|
||||
AppError::Channel(error) => ("channel", None, error_reason(error).into()),
|
||||
_ => ("backend", None, error_reason(&error.to_string()).into()),
|
||||
};
|
||||
tracing::warn!(event="backend.error", error_kind=kind, error_code=code, reason, error_hash=%fingerprint(&error.to_string()), "Backend operation failed");
|
||||
}
|
||||
|
||||
fn sanitize_frontend(value: &Value, depth: u8) -> Value {
|
||||
if depth > 6 {
|
||||
return json!("[TRUNCATED]");
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => Value::Object(
|
||||
map.iter()
|
||||
.take(40)
|
||||
.map(|(key, value)| {
|
||||
let lower = key.to_lowercase();
|
||||
let safe = if [
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"otp",
|
||||
"command",
|
||||
"content",
|
||||
"clipboard",
|
||||
"key",
|
||||
"cookie",
|
||||
"csrf",
|
||||
"authorization",
|
||||
"passphrase",
|
||||
]
|
||||
.iter()
|
||||
.any(|s| lower.contains(s))
|
||||
&& !lower.ends_with("_id")
|
||||
{
|
||||
json!("[REDACTED]")
|
||||
} else if matches!(key.as_str(), "level" | "domain" | "event" | "name")
|
||||
&& value.as_str().is_some_and(|s| {
|
||||
s.len() <= 96
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
||||
})
|
||||
{
|
||||
value.clone()
|
||||
} else if key.ends_with("_id")
|
||||
&& value
|
||||
.as_str()
|
||||
.is_some_and(|s| uuid::Uuid::parse_str(s).is_ok())
|
||||
{
|
||||
value.clone()
|
||||
} else {
|
||||
sanitize_frontend(value, depth + 1)
|
||||
};
|
||||
// Unknown property names are untrusted too.
|
||||
let key = if key.len() <= 64
|
||||
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
{
|
||||
key.clone()
|
||||
} else {
|
||||
fingerprint(key)
|
||||
};
|
||||
(key, safe)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.take(50)
|
||||
.map(|v| sanitize_frontend(v, depth + 1))
|
||||
.collect(),
|
||||
),
|
||||
Value::String(text) => json!({"hash":fingerprint(text)}),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct FrontendBatch {
|
||||
entries: Vec<Value>,
|
||||
}
|
||||
pub async fn frontend(
|
||||
ExtractState(state): ExtractState<Arc<State>>,
|
||||
Extension(owner): Extension<Owner>,
|
||||
Json(batch): Json<FrontendBatch>,
|
||||
) -> Result<Json<Value>> {
|
||||
state.login(&owner.0).await?;
|
||||
if batch.entries.len() > 50 {
|
||||
return Err(WebError::bad("Log batch exceeds 50 entries"));
|
||||
}
|
||||
if let Some(logs) = LOGS.get() {
|
||||
let mut rates = logs.rates.lock().unwrap_or_else(|e| e.into_inner());
|
||||
rates.retain(|_, (start, _)| start.elapsed() < Duration::from_secs(60));
|
||||
if rates.len() >= 1024 {
|
||||
return Err(WebError(
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
"Log rate limit exceeded".into(),
|
||||
));
|
||||
}
|
||||
let (_, count) = rates
|
||||
.entry(fingerprint(&owner.0))
|
||||
.or_insert((Instant::now(), 0));
|
||||
if *count >= 120 {
|
||||
return Err(WebError(
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
"Log rate limit exceeded".into(),
|
||||
));
|
||||
}
|
||||
*count += 1;
|
||||
drop(rates);
|
||||
for entry in batch.entries {
|
||||
let level = entry["level"]
|
||||
.as_str()
|
||||
.ok_or(WebError::bad("Invalid log level"))?;
|
||||
let fields = &entry;
|
||||
match level {
|
||||
"error" => tracing::error!(event="frontend.log", frontend=%fields),
|
||||
"warn" => tracing::warn!(event="frontend.log", frontend=%fields),
|
||||
"info" => tracing::info!(event="frontend.log", frontend=%fields),
|
||||
"debug" => tracing::debug!(event="frontend.log", frontend=%fields),
|
||||
_ => return Err(WebError::bad("Invalid log level")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({"status":"accepted"})))
|
||||
}
|
||||
|
||||
pub async fn diagnostics() -> Result<Response> {
|
||||
let dir = LOGS.get().map(|logs| logs.dir.clone());
|
||||
let bytes = tokio::task::spawn_blocking(move || -> std::io::Result<Vec<u8>> {
|
||||
flush();
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
zip.start_file("manifest.json",options)?;
|
||||
zip.write_all(serde_json::to_string_pretty(&json!({"runtime":"web","server_version":env!("CARGO_PKG_VERSION"),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"log_budget_bytes":20*1024*1024,"capabilities":["ssh","telnet","vnc","sftp","browserFiles","backups"]}))?.as_bytes())?;
|
||||
if let Some(dir) = dir {
|
||||
let mut budget = 20*1024*1024;
|
||||
for (path,size,_) in log_files(&dir).into_iter().rev() {
|
||||
if size > budget { continue; } budget-=size;
|
||||
zip.start_file(format!("logs/{}",path.file_name().unwrap().to_string_lossy()),options)?;
|
||||
let mut bytes = Vec::new(); File::open(path)?.take(size).read_to_end(&mut bytes)?;
|
||||
zip.write_all(&bytes)?;
|
||||
}
|
||||
}
|
||||
Ok(zip.finish()?.into_inner())
|
||||
}).await.map_err(|_|WebError::bad("Diagnostics export failed"))?.map_err(|_|WebError::bad("Diagnostics export failed"))?;
|
||||
Ok((
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, "application/zip"),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"nyaterm-diagnostics.zip\"",
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn test_logs(dir: PathBuf) -> (Arc<Logs>, mpsc::Receiver<Job>) {
|
||||
let (sender, receiver) = mpsc::sync_channel(2048);
|
||||
(
|
||||
Arc::new(Logs {
|
||||
sender,
|
||||
dir,
|
||||
level: AtomicU8::new(1),
|
||||
retention: AtomicU32::new(7),
|
||||
dropped: AtomicU64::new(0),
|
||||
rates: Mutex::new(HashMap::new()),
|
||||
}),
|
||||
receiver,
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn writer_rotates_and_exports_live_file_sizes() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (logs, receiver) = test_logs(temp.path().to_owned());
|
||||
let worker = logs.clone();
|
||||
let thread = std::thread::spawn(move || run_writer(worker, receiver, std::io::sink()));
|
||||
for _ in 0..110 {
|
||||
logs.sender
|
||||
.send(Job::Entry(vec![b'x'; 100 * 1024]))
|
||||
.unwrap();
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
logs.sender.send(Job::Flush(tx)).unwrap();
|
||||
rx.recv().unwrap();
|
||||
let files = log_files(temp.path());
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.all(|(_, size, _)| *size > 0 && *size <= ROTATE_BYTES)
|
||||
);
|
||||
assert_eq!(
|
||||
files.iter().map(|(_, size, _)| size).sum::<u64>(),
|
||||
110 * 100 * 1024
|
||||
);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
logs.sender.send(Job::Shutdown(tx)).unwrap();
|
||||
rx.recv().unwrap();
|
||||
thread.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn pruning_respects_retention_and_total_budget() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let old = temp.path().join("nyaterm-web-old.jsonl");
|
||||
File::create(&old)
|
||||
.unwrap()
|
||||
.set_modified(SystemTime::now() - Duration::from_secs(8 * 86400))
|
||||
.unwrap();
|
||||
let (logs, _) = test_logs(temp.path().to_owned());
|
||||
prune(&logs, None);
|
||||
assert!(!old.exists());
|
||||
let large = temp.path().join("nyaterm-web-large.jsonl");
|
||||
File::create(&large)
|
||||
.unwrap()
|
||||
.set_len(TOTAL_BYTES + 1)
|
||||
.unwrap();
|
||||
let unrelated = temp.path().join("database.redb");
|
||||
std::fs::write(&unrelated, b"preserve").unwrap();
|
||||
prune(&logs, None);
|
||||
assert!(!large.exists());
|
||||
assert!(unrelated.exists());
|
||||
}
|
||||
#[test]
|
||||
fn unwritable_file_sink_preserves_stdout() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let blocked = temp.path().join("file");
|
||||
std::fs::write(&blocked, b"blocked").unwrap();
|
||||
let (logs, receiver) = test_logs(blocked);
|
||||
logs.sender.send(Job::Entry(b"fixture\n".to_vec())).unwrap();
|
||||
let (tx, _) = mpsc::channel();
|
||||
logs.sender.send(Job::Shutdown(tx)).unwrap();
|
||||
assert_eq!(run_writer(logs, receiver, Vec::new()), b"fixture\n");
|
||||
}
|
||||
#[test]
|
||||
fn frontend_secrets_and_arbitrary_messages_never_persist() {
|
||||
let value = sanitize_frontend(&json!({"domain":"ui.error","event":"command.error","message":"token abc123","data":{"private_key":"PEM", "password":42,"command":"cat /secret","host":"private.internal"},"error":{"message":"password abc123"}}),0).to_string();
|
||||
assert!(!value.contains("abc123"));
|
||||
assert!(!value.contains("PEM"));
|
||||
assert!(!value.contains("private.internal"));
|
||||
assert!(!value.contains("/secret"));
|
||||
assert!(value.contains("command.error"));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ struct ChannelGuard(Option<russh::Channel<russh::client::Msg>>);
|
||||
impl Drop for ChannelGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(channel) = self.0.take() {
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), channel.close()).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ use std::{
|
||||
};
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard, mpsc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::Instrument;
|
||||
|
||||
pub struct Handler {
|
||||
state: Arc<State>,
|
||||
@@ -447,13 +448,14 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
)
|
||||
.await?;
|
||||
let id = session.id.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
let result = tokio::select! {
|
||||
_ = session.cancel.cancelled() => Err(WebError::bad("Session creation cancelled")),
|
||||
result = tokio::time::timeout(Duration::from_secs(120), connect(&state,&session,target)) => result.unwrap_or(Err(WebError::bad("SSH connection timed out"))),
|
||||
};
|
||||
match result {
|
||||
Ok(channel) => {
|
||||
tracing::info!(event="session.connected", session_id=%session.id, "SSH session connected");
|
||||
session.ready.store(true, Ordering::Release);
|
||||
state
|
||||
.event(&session.owner, "sessions-changed", Value::Null)
|
||||
@@ -461,6 +463,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
terminal::run(channel, commands, sender, session.cancel.clone()).await;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(event="session.connect_failed", session_id=%session.id, reason=crate::observability::error_reason(&error.1), "SSH connection failed");
|
||||
state
|
||||
.event(
|
||||
&session.owner,
|
||||
@@ -476,6 +479,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
Ok(id)
|
||||
}
|
||||
pub(crate) async fn finish(state: &State, session: &WebSession) {
|
||||
tracing::info!(event="session.closed", session_id=%session.id, "Web session closed");
|
||||
session.cancel.cancel();
|
||||
// Let cancelled transfers remove their temporary files while SSH is still alive.
|
||||
let _transfers = tokio::time::timeout(
|
||||
@@ -694,7 +698,15 @@ pub async fn ws_route(
|
||||
.max_message_size(64 * 1024)
|
||||
.max_frame_size(64 * 1024)
|
||||
.max_write_buffer_size(256 * 1024)
|
||||
.on_upgrade(move |socket| socket_loop(session, receiver, socket)))
|
||||
.on_upgrade({
|
||||
let span = tracing::Span::current();
|
||||
let guard = crate::observability::StreamLogGuard::new("terminal.disconnected", Some(session.id.clone()));
|
||||
move |socket| async move {
|
||||
let _guard = guard;
|
||||
tracing::info!(event="terminal.attached", session_id=%session.id, "Web stream attached");
|
||||
socket_loop(session, receiver, socket).await;
|
||||
}.instrument(span)
|
||||
}))
|
||||
}
|
||||
struct Attachment(Arc<WebSession>);
|
||||
impl Drop for Attachment {
|
||||
|
||||
@@ -210,7 +210,7 @@ pub async fn command(
|
||||
.await?
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote write failed"))?;
|
||||
apply_mode(&sftp, path(args, "path")?, permissions).await?;
|
||||
Value::Null
|
||||
}
|
||||
@@ -326,7 +326,7 @@ pub async fn command(
|
||||
.take(limit + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote read failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote read failed"))?;
|
||||
if bytes.len() as u64 > limit {
|
||||
return Err(WebError::bad("Remote file exceeds preview limit"));
|
||||
}
|
||||
@@ -351,7 +351,7 @@ pub async fn command(
|
||||
.take(limit + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote read failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote read failed"))?;
|
||||
if bytes.len() as u64 > limit {
|
||||
return Err(WebError::bad("Remote file is too large"));
|
||||
}
|
||||
@@ -392,7 +392,7 @@ pub async fn command(
|
||||
.take(4 * 1024 * 1024 + 1)
|
||||
.read_to_end(&mut old)
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote read failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote read failed"))?;
|
||||
hash_changed = files::content_hash(&old) != expected;
|
||||
}
|
||||
if changed || hash_changed {
|
||||
@@ -406,10 +406,10 @@ pub async fn command(
|
||||
let mut file = sftp.create(p).await?;
|
||||
file.write_all(content.as_bytes())
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote write failed"))?;
|
||||
file.shutdown()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote write failed"))?;
|
||||
let attrs = sftp.metadata(p).await?;
|
||||
json!(files::WriteRemoteTextResult::saved(
|
||||
attrs.mtime.unwrap_or(0) as u64,
|
||||
@@ -604,7 +604,7 @@ async fn copy_entry(state: &Arc<State>, owner: &str, command: &str, args: &Value
|
||||
let size = reader
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote read failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote read failed"))?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -615,13 +615,13 @@ async fn copy_entry(state: &Arc<State>, owner: &str, command: &str, args: &Value
|
||||
writer
|
||||
.write_all(&buffer[..size])
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote write failed"))?;
|
||||
transfer.progress("progress").await;
|
||||
}
|
||||
writer
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("Remote write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "Remote write failed"))?;
|
||||
if transfer.bytes != transfer.total {
|
||||
return Err(WebError::bad("Source changed during copy"));
|
||||
}
|
||||
@@ -676,6 +676,7 @@ impl Transfer {
|
||||
self.state.event(&self.owner,"transfer-event",json!({"id":self.id,"session_id":self.session_id,"file_name":self.path.rsplit('/').next().unwrap_or("file"),"remote_path":self.path,"local_path":"","direction":self.direction,"kind":"file","bytes_transferred":self.bytes,"total_size":self.total,"size":self.total,"status":status})).await;
|
||||
}
|
||||
async fn finish(&mut self, status: &str) {
|
||||
tracing::info!(event="file.transfer_finished", transfer_id=%self.id, bytes=self.bytes, "Web file transfer finished");
|
||||
self.progress(status).await;
|
||||
self.done = true;
|
||||
}
|
||||
@@ -690,7 +691,7 @@ impl Drop for Transfer {
|
||||
let id = self.id.clone();
|
||||
let sid = self.session_id.clone();
|
||||
let done = self.done;
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
// Session shutdown waits for permits, including asynchronous cleanup.
|
||||
let _permits = permits;
|
||||
if let Some(path) = temporary {
|
||||
@@ -871,12 +872,12 @@ pub async fn upload(
|
||||
}
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|_| WebError::bad("SFTP write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "SFTP write failed"))?;
|
||||
transfer.progress("progress").await;
|
||||
}
|
||||
file.shutdown()
|
||||
.await
|
||||
.map_err(|_| WebError::bad("SFTP write failed"))?;
|
||||
.map_err(|error| WebError::io(&error, "SFTP write failed"))?;
|
||||
if overwrite {
|
||||
sftp.rename_replace(&temporary, &transfer.path)
|
||||
.await
|
||||
|
||||
@@ -114,6 +114,10 @@ impl State {
|
||||
))
|
||||
}
|
||||
pub async fn close_owner(&self, owner: &str) {
|
||||
tracing::info!(
|
||||
event = "auth.session_closed",
|
||||
"Web login closed and sessions cancelled"
|
||||
);
|
||||
if let Some(login) = self.logins.lock().await.remove(owner) {
|
||||
login.cancel.cancel();
|
||||
}
|
||||
@@ -141,7 +145,7 @@ impl Drop for PromptCleanup {
|
||||
fn drop(&mut self) {
|
||||
let state = self.state.clone();
|
||||
let id = self.id.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
state.prompts.lock().await.remove(&id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
.await?;
|
||||
let id = session.id.clone();
|
||||
let startup = args["startupCommand"].clone();
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
let result = async {
|
||||
let transport = crate::network::open(
|
||||
state.clone(),
|
||||
@@ -120,6 +120,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
)
|
||||
.await?;
|
||||
session.ready.store(true, Ordering::Release);
|
||||
tracing::info!(event="session.connected", session_id=%session.id, operation="telnet", "Telnet session connected");
|
||||
if let Some(id) = &session.connection_id {
|
||||
nyaterm_core::storage::mark_connection_used(id)?;
|
||||
}
|
||||
@@ -135,6 +136,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
}
|
||||
.await;
|
||||
let end = if let Err(e) = result {
|
||||
tracing::warn!(event="session.connect_failed", session_id=%session.id, operation="telnet", reason=crate::observability::error_reason(&e.1), "Telnet session failed");
|
||||
state
|
||||
.event(
|
||||
&session.owner,
|
||||
|
||||
@@ -29,6 +29,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::Instrument;
|
||||
|
||||
pub struct WebVnc {
|
||||
pub manager: Arc<VncSessionManager>,
|
||||
@@ -62,11 +63,20 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
let transport_state = state.clone();
|
||||
let transport_owner = owner.to_owned();
|
||||
let transport_cancel = cancel.clone();
|
||||
let event_span = tracing::Span::current();
|
||||
let transport_span = tracing::Span::current();
|
||||
let context = VncContext {
|
||||
events: Arc::new(move |_, event, payload| {
|
||||
let _entered = event_span.enter();
|
||||
if event.starts_with("vnc-state-") {
|
||||
if let Some(state) = payload["state"].as_str() {
|
||||
*event_latest_state.lock().unwrap() = state.into();
|
||||
tracing::info!(
|
||||
event = "vnc.state_changed",
|
||||
session_id = event.trim_start_matches("vnc-state-"),
|
||||
reason = state,
|
||||
"VNC state changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(id) = payload["requestId"].as_str() {
|
||||
@@ -84,7 +94,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
let state = event_state.clone();
|
||||
let owner = event_owner.clone();
|
||||
let event = event.to_owned();
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
state.event(&owner, &event, payload).await;
|
||||
});
|
||||
Ok(())
|
||||
@@ -93,11 +103,15 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
let state = transport_state.clone();
|
||||
let owner = transport_owner.clone();
|
||||
let cancel = transport_cancel.clone();
|
||||
Box::pin(async move {
|
||||
crate::network::open(state, owner, cancel, host, port, network)
|
||||
.await
|
||||
.map_err(|e| AppError::Channel(e.1))
|
||||
})
|
||||
let span = transport_span.clone();
|
||||
Box::pin(
|
||||
async move {
|
||||
crate::network::open(state, owner, cancel, host, port, network)
|
||||
.await
|
||||
.map_err(|e| AppError::Channel(e.1))
|
||||
}
|
||||
.instrument(span),
|
||||
)
|
||||
}),
|
||||
};
|
||||
let web = Arc::new(WebVnc {
|
||||
@@ -138,7 +152,7 @@ pub async fn create(state: Arc<State>, owner: &str, args: Value) -> Result<Strin
|
||||
nyaterm_core::storage::mark_connection_used(id)?;
|
||||
}
|
||||
let id = session.id.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::observability::spawn(async move {
|
||||
session.cancel.cancelled().await;
|
||||
let _ = web.manager.close(&web.context, &session.id).await;
|
||||
session::finish(&state, &session).await;
|
||||
@@ -248,7 +262,20 @@ pub async fn ws_route(
|
||||
session.attached.store(false, Ordering::Release);
|
||||
}
|
||||
})
|
||||
.on_upgrade(move |socket| socket_loop(session, socket)))
|
||||
.on_upgrade({
|
||||
let span = tracing::Span::current();
|
||||
let guard = crate::observability::StreamLogGuard::new(
|
||||
"vnc.disconnected",
|
||||
Some(session.id.clone()),
|
||||
);
|
||||
move |socket| {
|
||||
async move {
|
||||
let _guard = guard;
|
||||
tracing::info!(event="vnc.attached", session_id=%session.id, "Web stream attached");
|
||||
socket_loop(session, socket).await;
|
||||
}.instrument(span)
|
||||
}
|
||||
}))
|
||||
}
|
||||
async fn socket_loop(session: Arc<session::WebSession>, mut socket: WebSocket) {
|
||||
let SessionProtocol::Vnc(web) = &session.protocol else {
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Real server processes exercise independent key hierarchies and persisted restores.
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use nyaterm_core::{
|
||||
config,
|
||||
core::{backup, backup_crypto, portable_snapshot::*},
|
||||
storage,
|
||||
utils::crypto,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
io::{Cursor, Read, Write},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const LOGIN: &str = "integration-login-password-more-than-32";
|
||||
const DESKTOP_PASSWORD: &str = "desktop-backup-password";
|
||||
const WEB_PASSWORD: &str = "portable-web-backup-password";
|
||||
const SECRET: &str = "integration-secret-never-in-logs";
|
||||
struct Server {
|
||||
process: Child,
|
||||
client: reqwest::Client,
|
||||
base: String,
|
||||
cookie: String,
|
||||
csrf: String,
|
||||
}
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.process.kill();
|
||||
let _ = self.process.wait();
|
||||
}
|
||||
}
|
||||
impl Server {
|
||||
async fn start(data: &Path, dist: &Path, key: u8, subpath: &str) -> Self {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let base = format!("http://{address}/{subpath}");
|
||||
let process = Command::new(env!("CARGO_BIN_EXE_nyaterm-web"))
|
||||
.env_remove("NYATERM_WEB_PASSWORD_FILE")
|
||||
.env_remove("NYATERM_WEB_ENCRYPTION_KEY_FILE")
|
||||
.env("NYATERM_WEB_PASSWORD", LOGIN)
|
||||
.env("NYATERM_WEB_ENCRYPTION_KEY", STANDARD.encode([key; 32]))
|
||||
.env("NYATERM_WEB_DATA_DIR", data)
|
||||
.env("NYATERM_WEB_DIST", dist)
|
||||
.env("NYATERM_WEB_PUBLIC_URL", &base)
|
||||
.env("NYATERM_WEB_BIND", address.to_string())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut server = Self {
|
||||
process,
|
||||
client: reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
base,
|
||||
cookie: String::new(),
|
||||
csrf: String::new(),
|
||||
};
|
||||
let mut ready = false;
|
||||
for _ in 0..200 {
|
||||
if server.client.get(&server.base).send().await.is_ok() {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
server.process.try_wait().unwrap().is_none(),
|
||||
"Server exited during startup"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
assert!(ready);
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}api/auth/login", server.base))
|
||||
.header("origin", server.origin())
|
||||
.header("x-nyaterm-request", "1")
|
||||
.json(&json!({"password":LOGIN}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
server.cookie = response.headers()["set-cookie"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.into();
|
||||
server.csrf = response.json::<Value>().await.unwrap()["csrf"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.into();
|
||||
server
|
||||
}
|
||||
fn origin(&self) -> String {
|
||||
url::Url::parse(&self.base)
|
||||
.unwrap()
|
||||
.origin()
|
||||
.ascii_serialization()
|
||||
}
|
||||
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
|
||||
self.client
|
||||
.request(method, format!("{}api/{path}", self.base))
|
||||
.header("origin", self.origin())
|
||||
.header("cookie", &self.cookie)
|
||||
.header("x-nyaterm-csrf", &self.csrf)
|
||||
.header("x-nyaterm-request", "1")
|
||||
}
|
||||
async fn command(&self, command: &str, args: Value) -> Value {
|
||||
let response = self
|
||||
.request(reqwest::Method::POST, &format!("commands/{command}"))
|
||||
.json(&args)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200, "{command}");
|
||||
response.json().await.unwrap()
|
||||
}
|
||||
async fn import(&self, bytes: &[u8], password: &str) -> reqwest::Response {
|
||||
self.upload("backups/import", "password", password, bytes)
|
||||
.await
|
||||
}
|
||||
async fn upload(
|
||||
&self,
|
||||
route: &str,
|
||||
field: &str,
|
||||
value: &str,
|
||||
bytes: &[u8],
|
||||
) -> reqwest::Response {
|
||||
let mut body=format!("--fixture\r\nContent-Disposition: form-data; name=\"{field}\"\r\n\r\n{value}\r\n--fixture\r\nContent-Disposition: form-data; name=\"file\"; filename=\"fixture.nya\"\r\nContent-Type: application/octet-stream\r\n\r\n").into_bytes();
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(b"\r\n--fixture--\r\n");
|
||||
self.request(reqwest::Method::POST, route)
|
||||
.header("content-type", "multipart/form-data; boundary=fixture")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
async fn export(&self) -> Vec<u8> {
|
||||
let response = self
|
||||
.request(reqwest::Method::POST, "backups/export")
|
||||
.json(&json!({"password":WEB_PASSWORD}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
response.bytes().await.unwrap().to_vec()
|
||||
}
|
||||
async fn verify_secrets(&self) {
|
||||
for (command, id, expected) in [
|
||||
("get_saved_password_value", "password", SECRET),
|
||||
("get_saved_credential_password", "credential", SECRET),
|
||||
("get_ssh_key_private_key", "key", "private-key-fixture"),
|
||||
("get_connection_password_value", "connection", SECRET),
|
||||
("get_otp_secret_value", "otp", "JBSWY3DPEHPK3PXP"),
|
||||
] {
|
||||
assert_eq!(
|
||||
self.command(command, json!({"id":id})).await,
|
||||
json!(expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn desktop_web_cross_key_backups_are_atomic_and_diagnostics_are_redacted() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dist = temp.path().join("dist");
|
||||
std::fs::create_dir(&dist).unwrap();
|
||||
std::fs::write(dist.join("index.html"), "NyaTerm").unwrap();
|
||||
storage::init(&temp.path().join("desktop")).unwrap();
|
||||
crypto::set_master_password(Some(DESKTOP_PASSWORD.into()));
|
||||
config::save_passwords(&(),&serde_json::from_value(json!({"passwords":[{"id":"password","name":"fixture","password":crypto::encrypt(SECRET).unwrap()}]})).unwrap()).unwrap();
|
||||
config::save_credentials(&(),&serde_json::from_value(json!({"credentials":[{"id":"credential","name":"fixture","username":"test","password":crypto::encrypt(SECRET).unwrap()}]})).unwrap()).unwrap();
|
||||
config::save_keys(&(),&serde_json::from_value(json!({"keys":[{"id":"key","name":"fixture","key":crypto::encrypt("private-key-fixture").unwrap()}]})).unwrap()).unwrap();
|
||||
config::save_otp_entries(&(),&serde_json::from_value(json!({"entries":[{"id":"otp","otp_type":"totp","issuer":"fixture","username":"test","secret":crypto::encrypt("JBSWY3DPEHPK3PXP").unwrap()}]})).unwrap()).unwrap();
|
||||
config::save_sessions(&(),&serde_json::from_value(json!({"connections":[{"id":"connection","name":"fixture","type":"ssh","host":"private.example","auth":{"mode":"password","password":crypto::encrypt(SECRET).unwrap()}},{"id":"desktop-only","name":"serial preserved","type":"serial","port_name":"COM5"}]})).unwrap()).unwrap();
|
||||
let original_key = storage::load_master_key_token().unwrap();
|
||||
// This is exactly Desktop's existing snapshot + password encryption path.
|
||||
let desktop_snapshot = backup::build_backup("1.2.12").unwrap();
|
||||
let desktop_bytes = backup_crypto::encrypt_snapshot_bytes(
|
||||
&encode_portable_snapshot(&desktop_snapshot).unwrap(),
|
||||
DESKTOP_PASSWORD,
|
||||
)
|
||||
.unwrap();
|
||||
let source_data = temp.path().join("source");
|
||||
let target_data = temp.path().join("target");
|
||||
let source = Server::start(&source_data, &dist, 41, "").await;
|
||||
assert_eq!(
|
||||
source
|
||||
.import(&desktop_bytes, DESKTOP_PASSWORD)
|
||||
.await
|
||||
.status(),
|
||||
200
|
||||
);
|
||||
source.verify_secrets().await;
|
||||
let exported = source.export().await;
|
||||
assert!(
|
||||
!exported
|
||||
.windows(SECRET.len())
|
||||
.any(|bytes| bytes == SECRET.as_bytes())
|
||||
);
|
||||
// A Web backup decodes through Desktop's wire codec and its credentials decrypt with the wrapped backup key.
|
||||
let decoded = decode_portable_snapshot(
|
||||
&backup_crypto::decrypt_snapshot_bytes(&exported, WEB_PASSWORD).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
storage::save_master_key_token(decoded.master_key_token.as_deref().unwrap()).unwrap();
|
||||
crypto::set_master_password(Some(WEB_PASSWORD.into()));
|
||||
assert_eq!(
|
||||
crypto::decrypt(decoded.passwords.passwords[0].password.as_deref().unwrap()).unwrap(),
|
||||
SECRET
|
||||
);
|
||||
assert_eq!(
|
||||
crypto::decrypt(decoded.keys.keys[0].key.as_deref().unwrap()).unwrap(),
|
||||
"private-key-fixture"
|
||||
);
|
||||
storage::save_master_key_token(original_key.as_deref().unwrap()).unwrap();
|
||||
crypto::set_master_password(Some(DESKTOP_PASSWORD.into()));
|
||||
let target = Server::start(&target_data, &dist, 42, "terminal/").await;
|
||||
assert_eq!(target.import(&exported, WEB_PASSWORD).await.status(), 200);
|
||||
target.verify_secrets().await;
|
||||
let saved = target.command("get_saved_connections", json!({})).await;
|
||||
assert!(
|
||||
saved
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["id"] == "desktop-only")
|
||||
);
|
||||
let response = target.import(&exported, "wrong-password").await;
|
||||
assert_eq!(response.status(), 400);
|
||||
let id = response.headers()["x-nyaterm-request-id"]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
assert_eq!(response.json::<Value>().await.unwrap()["request_id"], id);
|
||||
target.verify_secrets().await;
|
||||
let mut corrupt = exported.clone();
|
||||
corrupt[20] ^= 1;
|
||||
assert_eq!(target.import(&corrupt, WEB_PASSWORD).await.status(), 400);
|
||||
target.verify_secrets().await;
|
||||
// Valid encrypted snapshots with invalid note relationships must roll back all preceding writes.
|
||||
let mut invalid = desktop_snapshot.clone();
|
||||
invalid.sessions.connections.clear();
|
||||
invalid.notes=serde_json::from_value(json!({"folders":[{"id":"cycle","name":"bad","parent_id":"cycle","sort_order":0,"created_at_ms":0,"updated_at_ms":0}],"notes":[]})).unwrap();
|
||||
invalid.payload_hash = calculate_payload_hash(&invalid).unwrap();
|
||||
let invalid = backup_crypto::encrypt_snapshot_bytes(
|
||||
&encode_portable_snapshot(&invalid).unwrap(),
|
||||
DESKTOP_PASSWORD,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
target.import(&invalid, DESKTOP_PASSWORD).await.status(),
|
||||
400
|
||||
);
|
||||
target.verify_secrets().await;
|
||||
assert_eq!(
|
||||
target
|
||||
.client
|
||||
.get(format!("{}api/diagnostics/export", target.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
401
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.client
|
||||
.post(format!("{}api/backups/export", target.base))
|
||||
.header("origin", target.origin())
|
||||
.header("cookie", &target.cookie)
|
||||
.json(&json!({"password":WEB_PASSWORD}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
403
|
||||
);
|
||||
let frontend=target.request(reqwest::Method::POST,"logs/frontend").json(&json!({"entries":[{"level":"error","domain":"ui.error","event":"fixture.failure","message":SECRET,"data":{"password":SECRET,"host":"private.example","content":SECRET}}]})).send().await.unwrap();
|
||||
assert_eq!(frontend.status(), 200);
|
||||
let zip = target
|
||||
.request(reqwest::Method::GET, "diagnostics/export")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(zip.status(), 200);
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(zip.bytes().await.unwrap())).unwrap();
|
||||
let mut logs = String::new();
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).unwrap();
|
||||
let mut text = String::new();
|
||||
file.read_to_string(&mut text).unwrap();
|
||||
logs.push_str(&text);
|
||||
assert!(!file.name().ends_with("redb"));
|
||||
}
|
||||
for secret in [
|
||||
SECRET,
|
||||
LOGIN,
|
||||
DESKTOP_PASSWORD,
|
||||
WEB_PASSWORD,
|
||||
"private.example",
|
||||
"private-key-fixture",
|
||||
] {
|
||||
assert!(!logs.contains(secret), "Diagnostics leaked a secret");
|
||||
}
|
||||
assert!(logs.contains("backup.import"), "{logs}");
|
||||
assert!(logs.contains("fixture.failure"), "{logs}");
|
||||
assert!(logs.contains(&id));
|
||||
let oversized_batch = target
|
||||
.request(reqwest::Method::POST, "logs/frontend")
|
||||
.json(&json!({"entries":vec![json!({"level":"info"});51]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(oversized_batch.status(), 400);
|
||||
let oversized_body = target
|
||||
.request(reqwest::Method::POST, "logs/frontend")
|
||||
.json(&json!({"entries":[{"level":"info","message":"x".repeat(256*1024)}]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(oversized_body.status(), 413);
|
||||
for _ in 0..119 {
|
||||
assert_eq!(
|
||||
target
|
||||
.request(reqwest::Method::POST, "logs/frontend")
|
||||
.json(&json!({"entries":[]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
200
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
target
|
||||
.request(reqwest::Method::POST, "logs/frontend")
|
||||
.json(&json!({"entries":[]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
429
|
||||
);
|
||||
let mut settings = target.command("get_app_settings", json!({})).await;
|
||||
settings["diagnostics"]["level"] = json!("warn");
|
||||
settings["diagnostics"]["retention_days"] = json!(1);
|
||||
target
|
||||
.command("save_app_settings", json!({"settings":settings}))
|
||||
.await;
|
||||
let suppressed_id = uuid::Uuid::new_v4().to_string();
|
||||
assert_eq!(
|
||||
target
|
||||
.request(reqwest::Method::POST, "commands/get_saved_connections")
|
||||
.header("x-nyaterm-request-id", &suppressed_id)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
200
|
||||
);
|
||||
let response = target
|
||||
.request(reqwest::Method::GET, "diagnostics/export")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(response.bytes().await.unwrap())).unwrap();
|
||||
for index in 0..archive.len() {
|
||||
let mut file = archive.by_index(index).unwrap();
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).unwrap();
|
||||
assert!(
|
||||
!content.contains(&suppressed_id),
|
||||
"Level changes must apply without a restart"
|
||||
);
|
||||
}
|
||||
settings["diagnostics"]["level"] = json!("info");
|
||||
target
|
||||
.command("save_app_settings", json!({"settings":settings}))
|
||||
.await;
|
||||
drop(target);
|
||||
let restarted = Server::start(&target_data, &dist, 42, "terminal/").await;
|
||||
restarted.verify_secrets().await;
|
||||
let import_json=br#"{"version":1,"sessions":[{"name":"imported","type":"ssh","host":"host.example","username":"root","auth":{"mode":"password","password":"fixture"}}]}"#;
|
||||
let response = restarted
|
||||
.upload("imports/connections", "source", "nyaterm_json", import_json)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(response.json::<Value>().await.unwrap()["imported"], 1);
|
||||
// Exercise every browser-supported source using uploaded bytes, with no server paths.
|
||||
let mut xts = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
xts.start_file("fixture.xsh", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
xts.write_all(b"[CONNECTION]\nProtocol=SSH\nHost=xshell.example\nPort=22\n[CONNECTION:AUTHENTICATION]\nUserName=root\n").unwrap();
|
||||
let xts = xts.finish().unwrap().into_inner();
|
||||
for (source, bytes) in [
|
||||
("xshell", xts.as_slice()),
|
||||
("mobaxterm", b"[Bookmarks]\nMoba=#109#0%mobaxterm.example%22%root%\n".as_slice()),
|
||||
("securecrt", br#"<VanDyke><key name="Sessions"><key name="Fixture"><string name="Hostname">securecrt.example</string><string name="Protocol Name">SSH2</string><string name="Username">root</string></key></key></VanDyke>"#.as_slice()),
|
||||
("electerm", br#"{"bookmarks":[{"id":"fixture","title":"Electerm","host":"electerm.example","username":"root","type":"ssh"}]}"#.as_slice()),
|
||||
("windterm", br#"[{"session.label":"WindTerm","session.protocol":"SSH","session.target":"windterm.example","session.autoLogin":"{\"Password\":\"windterm-secret\",\"PasswordEnabled\":true,\"session.user\":\"root\"}"}]"#.as_slice()),
|
||||
] {
|
||||
let response=restarted.upload("imports/connections","source",source,bytes).await;
|
||||
assert_eq!(response.status(),200,"{source}");
|
||||
assert_eq!(response.json::<Value>().await.unwrap()["imported"],1,"{source}");
|
||||
}
|
||||
let saved = restarted.command("get_saved_connections", json!({})).await;
|
||||
for (name, password) in [("WindTerm", "windterm-secret"), ("imported", "fixture")] {
|
||||
let id = saved
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|entry| entry["name"] == name)
|
||||
.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restarted
|
||||
.command("get_connection_password_value", json!({"id":id}))
|
||||
.await,
|
||||
json!(password)
|
||||
);
|
||||
}
|
||||
let over_limit = vec![b'x'; 10 * 1024 * 1024 + 1];
|
||||
assert_eq!(
|
||||
restarted
|
||||
.upload("imports/connections", "source", "nyaterm_json", &over_limit)
|
||||
.await
|
||||
.status(),
|
||||
413
|
||||
);
|
||||
assert_eq!(
|
||||
restarted
|
||||
.import(&vec![b'x'; 50 * 1024 * 1024 + 1], WEB_PASSWORD)
|
||||
.await
|
||||
.status(),
|
||||
413
|
||||
);
|
||||
restarted.verify_secrets().await;
|
||||
// Unknown source, encrypted profiles and Unix as well as Windows key paths are refused.
|
||||
assert_eq!(
|
||||
restarted
|
||||
.upload("imports/connections", "source", "finalshell", b"{}")
|
||||
.await
|
||||
.status(),
|
||||
400
|
||||
);
|
||||
let external_key=br#"[{"session.protocol":"SSH","session.target":"test","ssh.identityFilePath.unix":"/server/private/key"}]"#;
|
||||
assert_eq!(
|
||||
restarted
|
||||
.upload("imports/connections", "source", "windterm", external_key)
|
||||
.await
|
||||
.status(),
|
||||
400
|
||||
);
|
||||
let windterm =
|
||||
br#"[{"session.protocol":"SSH","session.target":"test","session.autoLogin":"encrypted"}]"#;
|
||||
assert_eq!(
|
||||
restarted
|
||||
.upload("imports/connections", "source", "windterm", windterm)
|
||||
.await
|
||||
.status(),
|
||||
400
|
||||
);
|
||||
}
|
||||
@@ -4,26 +4,12 @@ use crate::error::{AppError, AppResult};
|
||||
use crate::observability::{self, StructuredLog, StructuredLogLevel};
|
||||
use crate::utils::crypto;
|
||||
use crate::utils::fonts::{FontInfo, list_system_font_families, list_system_font_infos};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct KeywordHighlightImportResult {
|
||||
pub imported_rules: usize,
|
||||
pub updated_rules: usize,
|
||||
pub total_rules: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum KeywordHighlightImportFile {
|
||||
Config {
|
||||
keyword_highlights: Vec<config::KeywordHighlightRule>,
|
||||
},
|
||||
Rules(Vec<config::KeywordHighlightRule>),
|
||||
}
|
||||
pub use nyaterm_core::core::keyword_highlights::{
|
||||
KeywordHighlightImportResult, merge_keyword_highlight_rules, parse_keyword_highlight_import,
|
||||
};
|
||||
|
||||
fn schedule_cloud_sync_notify(app: tauri::AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -374,71 +360,6 @@ fn log_master_password_persist_action(action: &'static str, had_existing_passwor
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_keyword_highlight_import(raw: &str) -> AppResult<Vec<config::KeywordHighlightRule>> {
|
||||
let import_file: KeywordHighlightImportFile = serde_json::from_str(raw)
|
||||
.map_err(|error| AppError::Config(format!("Invalid highlight rules JSON: {error}")))?;
|
||||
|
||||
Ok(match import_file {
|
||||
KeywordHighlightImportFile::Config { keyword_highlights } => keyword_highlights,
|
||||
KeywordHighlightImportFile::Rules(rules) => rules,
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_keyword_highlight_rules(
|
||||
existing: &mut Vec<config::KeywordHighlightRule>,
|
||||
imported: Vec<config::KeywordHighlightRule>,
|
||||
mut next_id: impl FnMut() -> String,
|
||||
) -> AppResult<KeywordHighlightImportResult> {
|
||||
let mut imported_rules = 0;
|
||||
let mut updated_rules = 0;
|
||||
let mut indexes = existing
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, rule)| (!rule.id.trim().is_empty()).then(|| (rule.id.clone(), index)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for mut rule in imported {
|
||||
rule.name = rule.name.trim().to_string();
|
||||
rule.patterns = rule
|
||||
.patterns
|
||||
.into_iter()
|
||||
.map(|pattern| pattern.trim().to_string())
|
||||
.filter(|pattern| !pattern.is_empty())
|
||||
.collect();
|
||||
|
||||
if rule.name.is_empty() || rule.patterns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
rule.id = rule.id.trim().to_string();
|
||||
if rule.id.is_empty() {
|
||||
rule.id = next_id();
|
||||
}
|
||||
|
||||
if let Some(index) = indexes.get(&rule.id).copied() {
|
||||
existing[index] = rule;
|
||||
updated_rules += 1;
|
||||
} else {
|
||||
let id = rule.id.clone();
|
||||
existing.push(rule);
|
||||
indexes.insert(id, existing.len() - 1);
|
||||
imported_rules += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if imported_rules == 0 && updated_rules == 0 {
|
||||
return Err(AppError::Config(
|
||||
"No valid highlight rules found in import file".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(KeywordHighlightImportResult {
|
||||
imported_rules,
|
||||
updated_rules,
|
||||
total_rules: existing.len(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,110 +1,19 @@
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::utils::crypto::get_master_password;
|
||||
use aes_gcm::aead::{Aead, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"nyaterm-cloud-snapshot-v1:";
|
||||
const LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX: &[u8] = b"dragonfly-cloud-snapshot-v1:";
|
||||
|
||||
fn derive_snapshot_key_with_prefix(prefix: &[u8], master_password: &str) -> Key<Aes256Gcm> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(prefix);
|
||||
hasher.update(master_password.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
*Key::<Aes256Gcm>::from_slice(&digest)
|
||||
}
|
||||
|
||||
fn derive_snapshot_key(master_password: &str) -> Key<Aes256Gcm> {
|
||||
derive_snapshot_key_with_prefix(CLOUD_SNAPSHOT_KEY_PREFIX, master_password)
|
||||
}
|
||||
|
||||
fn derive_legacy_snapshot_key(master_password: &str) -> Key<Aes256Gcm> {
|
||||
derive_snapshot_key_with_prefix(LEGACY_CLOUD_SNAPSHOT_KEY_PREFIX, master_password)
|
||||
}
|
||||
|
||||
pub fn require_master_password() -> AppResult<String> {
|
||||
get_master_password()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| AppError::Config("master password is not set".to_string()))
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| AppError::Config("master password is not set".into()))
|
||||
}
|
||||
|
||||
pub fn encrypt_snapshot_bytes(plaintext: &[u8]) -> AppResult<Vec<u8>> {
|
||||
let master_password = require_master_password()?;
|
||||
let key = derive_snapshot_key(&master_password);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|error| AppError::Crypto(format!("cloud snapshot encryption failed: {error}")))?;
|
||||
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(combined)
|
||||
nyaterm_core::core::backup_crypto::encrypt_snapshot_bytes(
|
||||
plaintext,
|
||||
&require_master_password()?,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn decrypt_snapshot_bytes(ciphertext: &[u8]) -> AppResult<Vec<u8>> {
|
||||
let master_password = require_master_password()?;
|
||||
if ciphertext.len() < 13 {
|
||||
return Err(AppError::Crypto(
|
||||
"cloud snapshot ciphertext is too short".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
match decrypt_snapshot_bytes_with_key(ciphertext, &derive_snapshot_key(&master_password)) {
|
||||
Ok(plaintext) => Ok(plaintext),
|
||||
Err(new_error) => decrypt_snapshot_bytes_with_key(
|
||||
ciphertext,
|
||||
&derive_legacy_snapshot_key(&master_password),
|
||||
)
|
||||
.map_err(|legacy_error| {
|
||||
AppError::Crypto(format!(
|
||||
"cloud snapshot decryption failed: NyaTerm key prefix failed ({new_error}); legacy Dragonfly key prefix failed ({legacy_error})"
|
||||
))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt_snapshot_bytes_with_key(ciphertext: &[u8], key: &Key<Aes256Gcm>) -> AppResult<Vec<u8>> {
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let (nonce_bytes, payload) = ciphertext.split_at(12);
|
||||
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
|
||||
cipher
|
||||
.decrypt(nonce, payload)
|
||||
.map_err(|error| AppError::Crypto(format!("cloud snapshot decryption failed: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::utils::crypto::set_master_password;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static MASTER_PASSWORD_TEST_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn encrypt_snapshot_bytes_with_key_for_test(plaintext: &[u8], key: &Key<Aes256Gcm>) -> Vec<u8> {
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher.encrypt(&nonce, plaintext).expect("encrypt snapshot");
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
combined
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_dragonfly_cloud_snapshot_prefix_can_be_decrypted() {
|
||||
let _guard = MASTER_PASSWORD_TEST_LOCK
|
||||
.lock()
|
||||
.expect("lock master password");
|
||||
set_master_password(Some("secret".to_string()));
|
||||
let legacy_key = derive_legacy_snapshot_key("secret");
|
||||
let ciphertext = encrypt_snapshot_bytes_with_key_for_test(b"snapshot", &legacy_key);
|
||||
|
||||
assert_eq!(
|
||||
decrypt_snapshot_bytes(&ciphertext).expect("decrypt legacy snapshot"),
|
||||
b"snapshot"
|
||||
);
|
||||
|
||||
set_master_password(None);
|
||||
}
|
||||
nyaterm_core::core::backup_crypto::decrypt_snapshot_bytes(
|
||||
ciphertext,
|
||||
&require_master_password()?,
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,24 @@
|
||||
//! Import sessions from Xshell (.xts), MobaXterm (.mxtsessions), WindTerm (.sessions),
|
||||
//! SecureCRT (.xml), FinalShell conn directories, NyaTerm JSON files, and Electerm bookmarks.
|
||||
|
||||
use crate::config::{
|
||||
self, AiExecutionProfile, ConnectionAuth, ConnectionType, Group, SavedConnection,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
#[cfg(not(test))]
|
||||
use crate::utils::crypto;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use crate::error::AppResult;
|
||||
use tauri::Emitter;
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn encrypt_import_secret(plaintext: &str) -> AppResult<String> {
|
||||
crypto::encrypt(plaintext)
|
||||
pub fn import_sessions(
|
||||
app: tauri::AppHandle,
|
||||
file_path: String,
|
||||
windterm_master_password: Option<String>,
|
||||
) -> AppResult<usize> {
|
||||
let count =
|
||||
nyaterm_core::core::importer::import_sessions(&app, file_path, windterm_master_password)?;
|
||||
if count > 0 {
|
||||
let _ = app.emit("connections-changed", ());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn encrypt_import_secret(plaintext: &str) -> AppResult<String> {
|
||||
Ok(format!("test-encrypted:{plaintext}"))
|
||||
pub fn import_termius_sessions(
|
||||
app: tauri::AppHandle,
|
||||
indexed_db_path: Option<String>,
|
||||
) -> AppResult<usize> {
|
||||
let count = nyaterm_core::core::importer::import_termius_sessions(&app, indexed_db_path)?;
|
||||
if count > 0 {
|
||||
let _ = app.emit("connections-changed", ());
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
include!("types.rs");
|
||||
include!("text.rs");
|
||||
include!("common.rs");
|
||||
include!("xshell.rs");
|
||||
include!("mobaxterm.rs");
|
||||
include!("windterm.rs");
|
||||
include!("securecrt.rs");
|
||||
include!("finalshell.rs");
|
||||
include!("nyaterm_json.rs");
|
||||
include!("electerm.rs");
|
||||
include!("termius.rs");
|
||||
include!("merge.rs");
|
||||
include!("tests.rs");
|
||||
@@ -43,12 +43,3 @@ pub fn build_portable_snapshot(
|
||||
snapshot.payload_hash = calculate_payload_hash(&snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn sync_settings_payload_changed(
|
||||
previous: &config::AppSettings,
|
||||
next: &config::AppSettings,
|
||||
) -> AppResult<bool> {
|
||||
let previous = PortableAppSettings::from_app_settings(previous, &PortableSnapshotKind::Sync);
|
||||
let next = PortableAppSettings::from_app_settings(next, &PortableSnapshotKind::Sync);
|
||||
Ok(serde_json::to_vec(&previous)? != serde_json::to_vec(&next)?)
|
||||
}
|
||||
@@ -1,41 +1,8 @@
|
||||
use crate::config::{
|
||||
self, ActivityBarLayout, AppSettings, DiagnosticsSettings, InteractionSettings, SearchSettings,
|
||||
TerminalSettings, TransferSettings, TranslationSettings,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::path::{Path, PathBuf};
|
||||
use super::{QuickCommandsStore, SessionManager};
|
||||
use crate::config;
|
||||
use crate::error::AppResult;
|
||||
pub use nyaterm_core::core::portable_snapshot::*;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use super::{QuickCommandsStore, SessionManager};
|
||||
|
||||
const PORTABLE_SNAPSHOT_SCHEMA_VERSION: u32 = 3;
|
||||
const SNAPSHOT_META_KEY: &str = "meta";
|
||||
const SNAPSHOT_JSON_PORTABLE_SETTINGS: &str = "portable-settings";
|
||||
const SNAPSHOT_ZIP_MANIFEST_NAME: &str = "manifest.json";
|
||||
const SNAPSHOT_ZIP_PAYLOAD_NAME: &str = "snapshot.redb";
|
||||
const MAX_COMPRESSED_SNAPSHOT_PAYLOAD_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
const SNAPSHOT_META_TABLE: TableDefinition<&str, &str> = TableDefinition::new("snapshot_meta");
|
||||
const SNAPSHOT_ENTITIES_TABLE: TableDefinition<&str, &str> = TableDefinition::new("entity_docs");
|
||||
const SNAPSHOT_V2_JSON_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("json_docs");
|
||||
const SNAPSHOT_V2_TEXT_DOCS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("text_docs");
|
||||
|
||||
include!("types.rs");
|
||||
include!("build.rs");
|
||||
include!("codec.rs");
|
||||
include!("apply.rs");
|
||||
include!("hash.rs");
|
||||
include!("legacy.rs");
|
||||
include!("redb_codec.rs");
|
||||
include!("temp_redb.rs");
|
||||
include!("tests.rs");
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function BrowserBackupDialog({
|
||||
mode,
|
||||
busy,
|
||||
error,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
mode: "import" | "export";
|
||||
busy: boolean;
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (password: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const importing = mode === "import";
|
||||
const valid =
|
||||
!!password &&
|
||||
new TextEncoder().encode(password).length <= 1024 &&
|
||||
(importing ? overwrite : password === confirmation);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
disablePointerDismissal
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[400px]" showCloseButton={!busy}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(importing ? "web.backupImportTitle" : "web.backupExportTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(importing ? "web.backupImportDescription" : "web.backupExportDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (valid && !busy) void onSubmit(password);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">{t("web.backupPassword")}</Label>
|
||||
<Input
|
||||
id="backup-password"
|
||||
type="password"
|
||||
autoFocus
|
||||
autoComplete={importing ? "off" : "new-password"}
|
||||
value={password}
|
||||
disabled={busy}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!importing && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-confirmation">{t("web.backupPasswordConfirm")}</Label>
|
||||
<Input
|
||||
id="backup-confirmation"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmation}
|
||||
disabled={busy}
|
||||
onChange={(event) => setConfirmation(event.target.value)}
|
||||
/>
|
||||
{confirmation && confirmation !== password && (
|
||||
<p className="text-sm text-destructive">{t("web.backupPasswordMismatch")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{importing && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="backup-overwrite"
|
||||
checked={overwrite}
|
||||
disabled={busy}
|
||||
onCheckedChange={(checked) => setOverwrite(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="backup-overwrite" className="text-sm leading-relaxed">
|
||||
{t("web.backupOverwriteConfirm")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={importing ? "destructive" : "default"}
|
||||
disabled={!valid || busy}
|
||||
>
|
||||
{t(
|
||||
busy
|
||||
? "web.backupWorking"
|
||||
: importing
|
||||
? "web.backupImportTitle"
|
||||
: "web.backupExportTitle",
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { open as openFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { openUrl } from "@/lib/backend/platform/opener";
|
||||
import { type ComponentType, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MdDataObject, MdOpenInNew, MdTerminal } from "react-icons/md";
|
||||
@@ -27,6 +25,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { useConfigTransfer } from "@/hooks/useConfigTransfer";
|
||||
import { pickBrowserFile } from "@/lib/backend/browserArtifacts";
|
||||
import { importBrowserConnections } from "@/lib/backend/configTransfer";
|
||||
import { open as openFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { openUrl } from "@/lib/backend/platform/opener";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
@@ -124,6 +127,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
|
||||
const [windtermMasterPassword, setWindtermMasterPassword] = useState("");
|
||||
const [windtermImporting, setWindtermImporting] = useState(false);
|
||||
const [confirmBackupRestore, setConfirmBackupRestore] = useState(false);
|
||||
const [browserImporting, setBrowserImporting] = useState(false);
|
||||
const docsUrl = i18n.language.toLowerCase().startsWith("zh")
|
||||
? SESSION_IMPORT_DOC_URLS.zh
|
||||
: SESSION_IMPORT_DOC_URLS.en;
|
||||
@@ -172,6 +176,36 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
|
||||
};
|
||||
|
||||
const handleSelect = async (source: ImportSource) => {
|
||||
if (runtime === "web") {
|
||||
if (browserImporting || source.picker === "directory" || source.id === "ssh_config") return;
|
||||
setBrowserImporting(true);
|
||||
try {
|
||||
const file = await pickBrowserFile(
|
||||
(source.extensions ?? []).map((ext) => `.${ext}`).join(","),
|
||||
);
|
||||
if (!file) return;
|
||||
const result = await importBrowserConnections(source.id, file);
|
||||
finishSessionImport(result.imported);
|
||||
for (const warning of result.warnings)
|
||||
if (warning === "credentials_not_in_export") toast.info(t("web.importCredentialsHint"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
domain: "settings.persistence",
|
||||
event: "connections.import_failed",
|
||||
message: "Browser connection import failed",
|
||||
error,
|
||||
});
|
||||
toast.error(
|
||||
String(error).includes("WindTerm profile resources")
|
||||
? t("web.windtermResourcesRequired")
|
||||
: t("savedConnections.importFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setBrowserImporting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
|
||||
if (source.id === "ssh_config") {
|
||||
@@ -274,7 +308,7 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<Dialog open={open} onOpenChange={(v) => !v && !browserImporting && onClose()}>
|
||||
<DialogContent className="w-[min(480px,calc(100vw-2rem))] sm:max-w-[480px] p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{t("savedConnections.importDialogTitle")}</DialogTitle>
|
||||
@@ -289,7 +323,12 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className="flex min-h-32 flex-col items-center justify-center gap-2 rounded-lg border p-3 text-center transition-colors hover:border-[var(--df-primary)] hover:bg-[color-mix(in_srgb,var(--df-primary)_8%,transparent)] cursor-pointer"
|
||||
disabled={
|
||||
browserImporting ||
|
||||
(runtime === "web" &&
|
||||
(source.picker === "directory" || source.id === "ssh_config"))
|
||||
}
|
||||
className="flex min-h-32 flex-col items-center justify-center gap-2 rounded-lg border p-3 text-center transition-colors hover:border-[var(--df-primary)] hover:bg-[color-mix(in_srgb,var(--df-primary)_8%,transparent)] cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||
style={{ borderColor: "var(--df-border)" }}
|
||||
onClick={() => handleSelect(source)}
|
||||
>
|
||||
@@ -302,7 +341,10 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
|
||||
className="text-[0.6rem] leading-tight text-center break-all"
|
||||
style={{ color: "var(--df-text-dimmed)" }}
|
||||
>
|
||||
{source.hint}
|
||||
{runtime === "web" &&
|
||||
(source.picker === "directory" || source.id === "ssh_config")
|
||||
? t("web.desktopOnly")
|
||||
: source.hint}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
|
||||
export type KeyMaterialMode = "content" | "file";
|
||||
|
||||
@@ -107,24 +108,37 @@ function KeyMaterialInput({
|
||||
<div className="space-y-1.5 rounded-md border bg-background/40 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="min-w-0 truncate text-xs font-medium">{title}</Label>
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as KeyMaterialMode)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<TabsList className="grid h-7 w-32 grid-cols-2">
|
||||
<TabsTrigger value="content" className="h-6 px-1.5 text-[0.6875rem]">
|
||||
<ClipboardPaste className="h-3 w-3" />
|
||||
{t("settings.keyInputContentMode")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="file" className="h-6 px-1.5 text-[0.6875rem]">
|
||||
<FileText className="h-3 w-3" />
|
||||
{t("settings.keyInputFileMode")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{runtime === "web" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void onPickFile()}
|
||||
>
|
||||
<FileText className="size-3" />
|
||||
{t("settings.keyInputFileMode")}
|
||||
</Button>
|
||||
) : (
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as KeyMaterialMode)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<TabsList className="grid h-7 w-32 grid-cols-2">
|
||||
<TabsTrigger value="content" className="h-6 px-1.5 text-[0.6875rem]">
|
||||
<ClipboardPaste className="h-3 w-3" />
|
||||
{t("settings.keyInputContentMode")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="file" className="h-6 px-1.5 text-[0.6875rem]">
|
||||
<FileText className="h-3 w-3" />
|
||||
{t("settings.keyInputFileMode")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
{mode === "content" ? (
|
||||
{mode === "content" || runtime === "web" ? (
|
||||
<Textarea
|
||||
value={contentValue}
|
||||
onChange={(event) => onContentChange(event.target.value)}
|
||||
@@ -195,7 +209,11 @@ export function KeyEditorDialog({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog disablePointerDismissal open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
|
||||
<Dialog
|
||||
disablePointerDismissal
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => !nextOpen && onCancel()}
|
||||
>
|
||||
<DialogContent className="w-[min(720px,calc(100vw-2rem))] max-w-none gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-5 py-3 pr-12">
|
||||
<DialogTitle className="text-sm">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { open as openFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { openUrl } from "@/lib/backend/platform/opener";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MdDataObject, MdOpenInNew, MdTerminal } from "react-icons/md";
|
||||
@@ -11,6 +9,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { pickBrowserFile } from "@/lib/backend/browserArtifacts";
|
||||
import { open as openFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { openUrl } from "@/lib/backend/platform/opener";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import { logger } from "@/lib/logger";
|
||||
import type {
|
||||
@@ -46,15 +48,20 @@ export function KeywordHighlightImportDialog({
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: t("settings.keywordHighlightImportJson"), extensions: ["json"] }],
|
||||
});
|
||||
const selected =
|
||||
runtime === "web"
|
||||
? await pickBrowserFile(".json")
|
||||
: await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: t("settings.keywordHighlightImportJson"), extensions: ["json"] }],
|
||||
});
|
||||
if (!selected || Array.isArray(selected)) return;
|
||||
if (selected instanceof File && selected.size > 1024 * 1024)
|
||||
throw new Error("Import exceeds 1 MiB");
|
||||
|
||||
onClose();
|
||||
const result = await invoke<KeywordHighlightImportResult>("import_keyword_highlight_rules", {
|
||||
filePath: selected,
|
||||
...(selected instanceof File ? { content: await selected.text() } : { filePath: selected }),
|
||||
});
|
||||
const nextSettings = await invoke<AppSettings>("get_app_settings");
|
||||
onImportedRules?.(nextSettings.terminal.keyword_highlights ?? []);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import { downloadJson, pickBrowserFile, readBrowserJson } from "@/lib/backend/browserArtifacts";
|
||||
import { open as openFileDialog, save as saveFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MdCheck,
|
||||
@@ -31,6 +28,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { downloadJson, pickBrowserFile, readBrowserJson } from "@/lib/backend/browserArtifacts";
|
||||
import { open as openFileDialog, save as saveFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import {
|
||||
ALL_THEME_COLOR_FIELDS,
|
||||
appendCustomThemePatch,
|
||||
@@ -110,6 +110,8 @@ export function ThemeDesignerDialog({
|
||||
applyAppearance,
|
||||
}: ThemeDesignerDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInFlight = useRef(false);
|
||||
const [fileBusy, setFileBusy] = useState(false);
|
||||
const customThemes = appearance.custom_themes ?? [];
|
||||
const [sourceThemeId, setSourceThemeId] = useState(appearance.theme || DEFAULT_THEME_ID);
|
||||
const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
|
||||
@@ -186,20 +188,19 @@ export function ThemeDesignerDialog({
|
||||
}
|
||||
|
||||
async function exportDraft() {
|
||||
if (!draft || !saveDraft()) return;
|
||||
if (runtime === "web") {
|
||||
downloadJson("nyaterm-theme.json", draft);
|
||||
toast.success(t("settings.themeDesignerExportSuccess"));
|
||||
return;
|
||||
}
|
||||
const outputPath = await saveFileDialog({
|
||||
defaultPath: "nyaterm-theme.json",
|
||||
filters: [{ name: "NyaTerm Theme", extensions: ["json"] }],
|
||||
});
|
||||
if (!outputPath) return;
|
||||
|
||||
if (!draft || fileInFlight.current) return;
|
||||
fileInFlight.current = true;
|
||||
setFileBusy(true);
|
||||
try {
|
||||
await invoke("write_theme_file", { outputPath, theme: draft });
|
||||
if (runtime === "web") downloadJson("nyaterm-theme.json", draft);
|
||||
else {
|
||||
const outputPath = await saveFileDialog({
|
||||
defaultPath: "nyaterm-theme.json",
|
||||
filters: [{ name: "NyaTerm Theme", extensions: ["json"] }],
|
||||
});
|
||||
if (!outputPath) return;
|
||||
await invoke("write_theme_file", { outputPath, theme: draft });
|
||||
}
|
||||
toast.success(t("settings.themeDesignerExportSuccess"));
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
@@ -209,20 +210,26 @@ export function ThemeDesignerDialog({
|
||||
error,
|
||||
});
|
||||
toast.error(t("settings.themeDesignerExportFailed", { error: String(error) }));
|
||||
} finally {
|
||||
fileInFlight.current = false;
|
||||
setFileBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importTheme() {
|
||||
const filePath =
|
||||
runtime === "web"
|
||||
? await pickBrowserFile(".json")
|
||||
: await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "NyaTerm Theme", extensions: ["json"] }],
|
||||
});
|
||||
if (!filePath || Array.isArray(filePath)) return;
|
||||
|
||||
if (fileInFlight.current) return;
|
||||
fileInFlight.current = true;
|
||||
setFileBusy(true);
|
||||
try {
|
||||
const filePath =
|
||||
runtime === "web"
|
||||
? await pickBrowserFile(".json")
|
||||
: await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "NyaTerm Theme", extensions: ["json"] }],
|
||||
});
|
||||
if (!filePath || Array.isArray(filePath)) return;
|
||||
|
||||
const imported =
|
||||
filePath instanceof File
|
||||
? await readBrowserJson<Theme>(filePath)
|
||||
@@ -246,6 +253,9 @@ export function ThemeDesignerDialog({
|
||||
error,
|
||||
});
|
||||
toast.error(t("settings.themeDesignerImportFailed", { error: String(error) }));
|
||||
} finally {
|
||||
fileInFlight.current = false;
|
||||
setFileBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,11 +296,16 @@ export function ThemeDesignerDialog({
|
||||
{t("settings.themeDesignerCopyTheme")}
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button size="sm" variant="outline" onClick={importTheme}>
|
||||
<Button size="sm" variant="outline" onClick={importTheme} disabled={fileBusy}>
|
||||
<MdUpload />
|
||||
{t("settings.themeDesignerImport")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={exportDraft} disabled={!draft}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={exportDraft}
|
||||
disabled={!draft || fileBusy}
|
||||
>
|
||||
<MdDownload />
|
||||
{t("settings.themeDesignerExport")}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { open as openFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { Copy, KeyRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -13,6 +12,9 @@ import { PrivateKeyViewDialog } from "@/components/dialog/security-auth/PrivateK
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { pickBrowserFile } from "@/lib/backend/browserArtifacts";
|
||||
import { open as openFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import { writeClipboardText } from "@/lib/clipboard";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
@@ -125,29 +127,23 @@ export function KeyManagementTab({
|
||||
setIsNew(true);
|
||||
};
|
||||
|
||||
const loadEditPassphrase = useCallback(
|
||||
async (id: string, requestId = editRequestRef.current) => {
|
||||
setPassphraseLoading(true);
|
||||
try {
|
||||
const passphrase = await invoke<string | null>(
|
||||
"get_ssh_key_passphrase",
|
||||
{ id },
|
||||
);
|
||||
if (editRequestRef.current !== requestId) return;
|
||||
setEditPassphrase(passphrase ?? "");
|
||||
setEditPassphraseLoaded(true);
|
||||
} catch {
|
||||
if (editRequestRef.current !== requestId) return;
|
||||
setEditPassphrase("");
|
||||
setEditPassphraseLoaded(true);
|
||||
} finally {
|
||||
if (editRequestRef.current === requestId) {
|
||||
setPassphraseLoading(false);
|
||||
}
|
||||
const loadEditPassphrase = useCallback(async (id: string, requestId = editRequestRef.current) => {
|
||||
setPassphraseLoading(true);
|
||||
try {
|
||||
const passphrase = await invoke<string | null>("get_ssh_key_passphrase", { id });
|
||||
if (editRequestRef.current !== requestId) return;
|
||||
setEditPassphrase(passphrase ?? "");
|
||||
setEditPassphraseLoaded(true);
|
||||
} catch {
|
||||
if (editRequestRef.current !== requestId) return;
|
||||
setEditPassphrase("");
|
||||
setEditPassphraseLoaded(true);
|
||||
} finally {
|
||||
if (editRequestRef.current === requestId) {
|
||||
setPassphraseLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEdit = async (key: SshKey) => {
|
||||
const requestId = ++editRequestRef.current;
|
||||
@@ -277,38 +273,65 @@ export function KeyManagementTab({
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
setPublicKeyLoadingId((current) =>
|
||||
current === key.id ? null : current,
|
||||
);
|
||||
setPublicKeyLoadingId((current) => (current === key.id ? null : current));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handlePickFile = async () => {
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
title: t("settings.selectKeyFileTitle"),
|
||||
});
|
||||
if (selected) {
|
||||
setEditKeyInputMode("file");
|
||||
setEditKeyData("");
|
||||
setEditKeyFilePath(selected);
|
||||
setEditKeyFileName(getPathFileName(selected));
|
||||
try {
|
||||
if (runtime === "web") {
|
||||
const file = await pickBrowserFile("");
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) throw new Error("Key file exceeds 1 MiB");
|
||||
setEditKeyInputMode("content");
|
||||
setEditKeyData(await file.text());
|
||||
setEditKeyFilePath("");
|
||||
setEditKeyFileName(file.name);
|
||||
return;
|
||||
}
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
title: t("settings.selectKeyFileTitle"),
|
||||
});
|
||||
if (selected) {
|
||||
setEditKeyInputMode("file");
|
||||
setEditKeyData("");
|
||||
setEditKeyFilePath(selected);
|
||||
setEditKeyFileName(getPathFileName(selected));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickCertFile = async () => {
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
title: t("settings.selectCertFileTitle"),
|
||||
});
|
||||
if (selected) {
|
||||
setEditCertExpanded(true);
|
||||
setEditCertInputMode("file");
|
||||
setEditCertData("");
|
||||
setEditCertFilePath(selected);
|
||||
setEditCertFileName(getPathFileName(selected));
|
||||
try {
|
||||
if (runtime === "web") {
|
||||
const file = await pickBrowserFile("");
|
||||
if (!file) return;
|
||||
if (file.size > 1024 * 1024) throw new Error("Certificate file exceeds 1 MiB");
|
||||
setEditCertExpanded(true);
|
||||
setEditCertInputMode("content");
|
||||
setEditCertData(await file.text());
|
||||
setEditCertFilePath("");
|
||||
setEditCertFileName(file.name);
|
||||
return;
|
||||
}
|
||||
const selected = await openFileDialog({
|
||||
multiple: false,
|
||||
title: t("settings.selectCertFileTitle"),
|
||||
});
|
||||
if (selected) {
|
||||
setEditCertExpanded(true);
|
||||
setEditCertInputMode("file");
|
||||
setEditCertData("");
|
||||
setEditCertFilePath(selected);
|
||||
setEditCertFileName(getPathFileName(selected));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { useConfigTransfer } from "@/hooks/useConfigTransfer";
|
||||
import { AVAILABLE_LANGUAGES } from "@/i18n";
|
||||
import { runtime, supports } from "@/lib/backend/runtime";
|
||||
import { HEADER_STATUS_MODES, normalizeHeaderStatusMode } from "@/lib/headerStatus";
|
||||
import {
|
||||
SettingFieldGrid,
|
||||
@@ -19,7 +19,7 @@ const HIDDEN_HEADER_STATUS_VALUE = "hidden";
|
||||
export function GeneralTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { appSettings, updateAppSettings, updateUi } = useApp();
|
||||
const { handleExportDiagnostics, handleOpenLogs } = useConfigTransfer();
|
||||
const { handleExportDiagnostics, handleOpenLogs, transferBusy } = useConfigTransfer();
|
||||
const headerStatusSettingValue =
|
||||
appSettings.ui.header_status_visible === false
|
||||
? HIDDEN_HEADER_STATUS_VALUE
|
||||
@@ -162,17 +162,19 @@ export function GeneralTab() {
|
||||
</SettingSelect>
|
||||
</SettingFieldGrid>
|
||||
|
||||
<SettingRow label={t("settings.openLogs")} desc={t("settings.openLogsDesc")}>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenLogs}>
|
||||
{t("settings.openLogs")}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
{runtime === "desktop" && (
|
||||
<SettingRow label={t("settings.openLogs")} desc={t("settings.openLogsDesc")}>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenLogs}>
|
||||
{t("settings.openLogs")}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.exportDiagnostics")}
|
||||
desc={t("settings.exportDiagnosticsDesc")}
|
||||
>
|
||||
<Button size="sm" onClick={handleExportDiagnostics}>
|
||||
<Button size="sm" disabled={transferBusy} onClick={handleExportDiagnostics}>
|
||||
{t("settings.exportDiagnostics")}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { downloadDir } from "@/lib/backend/platform/path";
|
||||
import { open as openDialog } from "@/lib/backend/platform/dialog";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -18,6 +16,9 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { open as openDialog } from "@/lib/backend/platform/dialog";
|
||||
import { downloadDir } from "@/lib/backend/platform/path";
|
||||
import { supports } from "@/lib/backend/runtime";
|
||||
import { getBuiltinRules, hexLuminance } from "@/lib/keywordHighlightPresets";
|
||||
import type { KeywordHighlightRule, SshKeepAliveMode } from "@/types/global";
|
||||
import { KeywordHighlightImportDialog } from "../dialog/terminal/KeywordHighlightImportDialog";
|
||||
@@ -71,9 +72,7 @@ function PathPickerInput({
|
||||
<div className="space-y-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm font-medium leading-5">{label}</Label>
|
||||
{desc && (
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{desc}</p>
|
||||
)}
|
||||
{desc && <p className="mt-1 text-xs leading-5 text-muted-foreground">{desc}</p>}
|
||||
</div>
|
||||
<div className="flex max-w-2xl flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
@@ -481,150 +480,152 @@ export function TerminalTab() {
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
<SettingSection
|
||||
title={t("settings.recordingSettings")}
|
||||
desc={t("settings.recordingSettingsDesc")}
|
||||
contentClassName="space-y-5"
|
||||
>
|
||||
<SettingRow
|
||||
label={t("settings.recordingAutoStart")}
|
||||
desc={t("settings.recordingAutoStartDesc")}
|
||||
{supports("recording") && (
|
||||
<SettingSection
|
||||
title={t("settings.recordingSettings")}
|
||||
desc={t("settings.recordingSettingsDesc")}
|
||||
contentClassName="space-y-5"
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.auto_start}
|
||||
onChange={(v) => updateRecording({ auto_start: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.recordingAutoStart")}
|
||||
desc={t("settings.recordingAutoStartDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.auto_start}
|
||||
onChange={(v) => updateRecording({ auto_start: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingSelect
|
||||
label={t("settings.recordingDefaultMode")}
|
||||
desc={t("settings.recordingDefaultModeDesc")}
|
||||
value={recording.default_mode || "transcript"}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) => updateRecording({ default_mode: v as "transcript" | "raw" })}
|
||||
>
|
||||
<SelectItem value="transcript">{t("settings.recordingModeTranscript")}</SelectItem>
|
||||
<SelectItem value="raw">{t("settings.recordingModeRaw")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
<PathPickerInput
|
||||
label={t("settings.recordingPath")}
|
||||
desc={t("settings.recordingPathDesc")}
|
||||
value={recording.base_path}
|
||||
placeholder={defaultDownloadDir}
|
||||
onChange={(v) => updateRecording({ base_path: v })}
|
||||
/>
|
||||
|
||||
<SettingInput
|
||||
label={t("settings.recordingPathTemplate")}
|
||||
desc={t("settings.recordingPathTemplateDesc")}
|
||||
value={recording.path_template}
|
||||
controlClassName="max-w-2xl"
|
||||
className="font-mono text-xs"
|
||||
onChange={(event) => updateRecording({ path_template: event.target.value })}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeTimestamps")}
|
||||
desc={t("settings.recordingIncludeTimestampsDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_timestamps}
|
||||
onChange={(v) => updateRecording({ include_timestamps: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeIoLabels")}
|
||||
desc={t("settings.recordingIncludeIoLabelsDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_io_labels}
|
||||
onChange={(v) => updateRecording({ include_io_labels: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeMetadata")}
|
||||
desc={t("settings.recordingIncludeMetadataDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_session_metadata}
|
||||
onChange={(v) => updateRecording({ include_session_metadata: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingSelect
|
||||
label={t("settings.recordingRotation")}
|
||||
desc={t("settings.recordingRotationDesc")}
|
||||
value={rotationValue}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) =>
|
||||
updateRecording({
|
||||
rotation:
|
||||
v === "daily"
|
||||
? { type: "daily" }
|
||||
: v === "size"
|
||||
? { type: "size", max_bytes: 10 * 1024 * 1024 }
|
||||
: { type: "session" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectItem value="session">{t("settings.recordingRotationSession")}</SelectItem>
|
||||
<SelectItem value="daily">{t("settings.recordingRotationDaily")}</SelectItem>
|
||||
<SelectItem value="size">{t("settings.recordingRotationSize")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
{recording.rotation?.type === "size" && (
|
||||
<SettingNumberInput
|
||||
label={t("settings.recordingRotationSizeLimit")}
|
||||
desc={t("settings.recordingRotationSizeLimitDesc")}
|
||||
min={1}
|
||||
max={10240}
|
||||
value={Math.max(1, Math.round(recording.rotation.max_bytes / (1024 * 1024)))}
|
||||
<SettingSelect
|
||||
label={t("settings.recordingDefaultMode")}
|
||||
desc={t("settings.recordingDefaultModeDesc")}
|
||||
value={recording.default_mode || "transcript"}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) =>
|
||||
onValueChange={(v) => updateRecording({ default_mode: v as "transcript" | "raw" })}
|
||||
>
|
||||
<SelectItem value="transcript">{t("settings.recordingModeTranscript")}</SelectItem>
|
||||
<SelectItem value="raw">{t("settings.recordingModeRaw")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
<PathPickerInput
|
||||
label={t("settings.recordingPath")}
|
||||
desc={t("settings.recordingPathDesc")}
|
||||
value={recording.base_path}
|
||||
placeholder={defaultDownloadDir}
|
||||
onChange={(v) => updateRecording({ base_path: v })}
|
||||
/>
|
||||
|
||||
<SettingInput
|
||||
label={t("settings.recordingPathTemplate")}
|
||||
desc={t("settings.recordingPathTemplateDesc")}
|
||||
value={recording.path_template}
|
||||
controlClassName="max-w-2xl"
|
||||
className="font-mono text-xs"
|
||||
onChange={(event) => updateRecording({ path_template: event.target.value })}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeTimestamps")}
|
||||
desc={t("settings.recordingIncludeTimestampsDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_timestamps}
|
||||
onChange={(v) => updateRecording({ include_timestamps: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeIoLabels")}
|
||||
desc={t("settings.recordingIncludeIoLabelsDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_io_labels}
|
||||
onChange={(v) => updateRecording({ include_io_labels: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeMetadata")}
|
||||
desc={t("settings.recordingIncludeMetadataDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_session_metadata}
|
||||
onChange={(v) => updateRecording({ include_session_metadata: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingSelect
|
||||
label={t("settings.recordingRotation")}
|
||||
desc={t("settings.recordingRotationDesc")}
|
||||
value={rotationValue}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) =>
|
||||
updateRecording({
|
||||
rotation: { type: "size", max_bytes: Math.max(1, v) * 1024 * 1024 },
|
||||
rotation:
|
||||
v === "daily"
|
||||
? { type: "daily" }
|
||||
: v === "size"
|
||||
? { type: "size", max_bytes: 10 * 1024 * 1024 }
|
||||
: { type: "session" },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectItem value="session">{t("settings.recordingRotationSession")}</SelectItem>
|
||||
<SelectItem value="daily">{t("settings.recordingRotationDaily")}</SelectItem>
|
||||
<SelectItem value="size">{t("settings.recordingRotationSize")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
{recording.rotation?.type === "size" && (
|
||||
<SettingNumberInput
|
||||
label={t("settings.recordingRotationSizeLimit")}
|
||||
desc={t("settings.recordingRotationSizeLimitDesc")}
|
||||
min={1}
|
||||
max={10240}
|
||||
value={Math.max(1, Math.round(recording.rotation.max_bytes / (1024 * 1024)))}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) =>
|
||||
updateRecording({
|
||||
rotation: { type: "size", max_bytes: Math.max(1, v) * 1024 * 1024 },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingSelect
|
||||
label={t("settings.recordingExistingFileBehavior")}
|
||||
desc={t("settings.recordingExistingFileBehaviorDesc")}
|
||||
value={recording.existing_file_behavior || "unique"}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) =>
|
||||
updateRecording({ existing_file_behavior: v as "unique" | "append" | "overwrite" })
|
||||
}
|
||||
>
|
||||
<SelectItem value="unique">{t("settings.recordingExistingUnique")}</SelectItem>
|
||||
<SelectItem value="append">{t("settings.recordingExistingAppend")}</SelectItem>
|
||||
<SelectItem value="overwrite">{t("settings.recordingExistingOverwrite")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
<SettingNumberInput
|
||||
label={t("settings.recordingMemoryLimit")}
|
||||
desc={t("settings.recordingMemoryLimitDesc")}
|
||||
min={1}
|
||||
max={100}
|
||||
value={recordingMemoryLimitMiB}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => updateRecording({ memory_limit_bytes: Math.max(1, v) * 1024 * 1024 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingSelect
|
||||
label={t("settings.recordingExistingFileBehavior")}
|
||||
desc={t("settings.recordingExistingFileBehaviorDesc")}
|
||||
value={recording.existing_file_behavior || "unique"}
|
||||
controlClassName="max-w-sm"
|
||||
onValueChange={(v) =>
|
||||
updateRecording({ existing_file_behavior: v as "unique" | "append" | "overwrite" })
|
||||
}
|
||||
>
|
||||
<SelectItem value="unique">{t("settings.recordingExistingUnique")}</SelectItem>
|
||||
<SelectItem value="append">{t("settings.recordingExistingAppend")}</SelectItem>
|
||||
<SelectItem value="overwrite">{t("settings.recordingExistingOverwrite")}</SelectItem>
|
||||
</SettingSelect>
|
||||
|
||||
<SettingNumberInput
|
||||
label={t("settings.recordingMemoryLimit")}
|
||||
desc={t("settings.recordingMemoryLimitDesc")}
|
||||
min={1}
|
||||
max={100}
|
||||
value={recordingMemoryLimitMiB}
|
||||
controlClassName="max-w-sm"
|
||||
onChange={(v) => updateRecording({ memory_limit_bytes: Math.max(1, v) * 1024 * 1024 })}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeBinaryTransfers")}
|
||||
desc={t("settings.recordingIncludeBinaryTransfersDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_binary_transfer_payloads}
|
||||
onChange={(v) => updateRecording({ include_binary_transfer_payloads: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
<SettingRow
|
||||
label={t("settings.recordingIncludeBinaryTransfers")}
|
||||
desc={t("settings.recordingIncludeBinaryTransfersDesc")}
|
||||
>
|
||||
<SettingSwitch
|
||||
checked={recording.include_binary_transfer_payloads}
|
||||
onChange={(v) => updateRecording({ include_binary_transfer_payloads: v })}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
<SettingSection contentClassName="space-y-4">
|
||||
<SettingRow label={t("settings.actionLinks")} desc={t("settings.actionLinksDesc")}>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pick: vi.fn(),
|
||||
export: vi.fn(),
|
||||
import: vi.fn(),
|
||||
diagnostics: vi.fn(),
|
||||
nativeOpen: vi.fn(),
|
||||
nativeSave: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/backend/runtime", () => ({ runtime: "web" }));
|
||||
vi.mock("@/lib/backend/browserArtifacts", () => ({ pickBrowserFile: mocks.pick }));
|
||||
vi.mock("@/lib/backend/platform/dialog", () => ({
|
||||
open: mocks.nativeOpen,
|
||||
save: mocks.nativeSave,
|
||||
}));
|
||||
vi.mock("@/lib/backend/configTransfer", () => ({
|
||||
exportBrowserConfig: mocks.export,
|
||||
importBrowserConfig: mocks.import,
|
||||
exportBrowserDiagnostics: mocks.diagnostics,
|
||||
}));
|
||||
vi.mock("@/context/AppContext", () => ({
|
||||
useApp: () => ({ appSettings: { security: { master_password: null } } }),
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn(), flush: vi.fn() } }));
|
||||
vi.mock("@/lib/windowManager", () => ({ openSettings: vi.fn() }));
|
||||
vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: mocks.success, error: mocks.error } }));
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
import { useConfigTransfer } from "./useConfigTransfer";
|
||||
|
||||
function Harness() {
|
||||
const flow = useConfigTransfer();
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => void flow.handleExport()}>Export</button>
|
||||
<button onClick={() => void flow.handleImport()}>Import</button>
|
||||
<button disabled={flow.transferBusy} onClick={() => void flow.handleExportDiagnostics()}>
|
||||
Diagnostics
|
||||
</button>
|
||||
{flow.passwordAlert}
|
||||
</>
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.pick.mockResolvedValue(null);
|
||||
mocks.export.mockResolvedValue(undefined);
|
||||
mocks.import.mockResolvedValue(undefined);
|
||||
mocks.diagnostics.mockResolvedValue(undefined);
|
||||
});
|
||||
describe("Web configuration transfer", () => {
|
||||
it("exports with a confirmed password without invoking native files", async () => {
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("Export"));
|
||||
fireEvent.change(screen.getByLabelText("web.backupPassword"), {
|
||||
target: { value: "backup-pass" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("web.backupPasswordConfirm"), {
|
||||
target: { value: "wrong" },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "web.backupExportTitle" }).hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
fireEvent.change(screen.getByLabelText("web.backupPasswordConfirm"), {
|
||||
target: { value: "backup-pass" },
|
||||
});
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: "web.backupExportTitle" })),
|
||||
);
|
||||
expect(mocks.export).toHaveBeenCalledWith("backup-pass");
|
||||
expect(mocks.nativeSave).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
it("treats picker cancellation as a normal outcome", async () => {
|
||||
render(<Harness />);
|
||||
await act(async () => fireEvent.click(screen.getByText("Import")));
|
||||
expect(mocks.import).not.toHaveBeenCalled();
|
||||
expect(mocks.error).not.toHaveBeenCalled();
|
||||
expect(mocks.nativeOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
it("requires overwrite confirmation and permits retry after failure", async () => {
|
||||
const file = new File(["backup"], "fixture.nya");
|
||||
mocks.pick.mockResolvedValue(file);
|
||||
mocks.import.mockRejectedValueOnce(new Error("incorrect password"));
|
||||
render(<Harness />);
|
||||
await act(async () => fireEvent.click(screen.getByText("Import")));
|
||||
fireEvent.change(screen.getByLabelText("web.backupPassword"), {
|
||||
target: { value: "backup-pass" },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "web.backupImportTitle" }).hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: "web.backupImportTitle" })),
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain("web.backupImportFailed");
|
||||
await act(async () =>
|
||||
fireEvent.click(screen.getByRole("button", { name: "web.backupImportTitle" })),
|
||||
);
|
||||
expect(mocks.import).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.import).toHaveBeenLastCalledWith(file, "backup-pass");
|
||||
expect(mocks.nativeOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
it("downloads diagnostics without opening a save dialog", async () => {
|
||||
render(<Harness />);
|
||||
await act(async () => fireEvent.click(screen.getByText("Diagnostics")));
|
||||
expect(mocks.diagnostics).toHaveBeenCalledOnce();
|
||||
expect(mocks.nativeSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+152
-48
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
open as openFileDialog,
|
||||
save as saveFileDialog,
|
||||
} from "@/lib/backend/platform/dialog";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { BrowserBackupDialog } from "@/components/dialog/app/BrowserBackupDialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -16,6 +13,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useApp } from "@/context/AppContext";
|
||||
import { pickBrowserFile } from "@/lib/backend/browserArtifacts";
|
||||
import {
|
||||
exportBrowserConfig,
|
||||
exportBrowserDiagnostics,
|
||||
importBrowserConfig,
|
||||
} from "@/lib/backend/configTransfer";
|
||||
import { open as openFileDialog, save as saveFileDialog } from "@/lib/backend/platform/dialog";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import { invoke } from "@/lib/invoke";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { openSettings } from "@/lib/windowManager";
|
||||
@@ -24,6 +29,26 @@ export function useConfigTransfer() {
|
||||
const { t } = useTranslation();
|
||||
const { appSettings } = useApp();
|
||||
const [showPasswordAlert, setShowPasswordAlert] = useState(false);
|
||||
const [backupMode, setBackupMode] = useState<"import" | "export" | null>(null);
|
||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [backupError, setBackupError] = useState("");
|
||||
const begin = () => {
|
||||
if (busyRef.current) return false;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
return true;
|
||||
};
|
||||
const finish = () => {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
};
|
||||
const closeBackup = () => {
|
||||
setBackupMode(null);
|
||||
setBackupFile(null);
|
||||
setBackupError("");
|
||||
};
|
||||
|
||||
const hasMasterPassword = !!appSettings.security.master_password;
|
||||
|
||||
@@ -34,15 +59,22 @@ export function useConfigTransfer() {
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (runtime === "web") {
|
||||
if (!busyRef.current) {
|
||||
setBackupError("");
|
||||
setBackupMode("export");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!ensureMasterPassword()) return;
|
||||
|
||||
const path = await saveFileDialog({
|
||||
filters: [{ name: "NyaTerm Backup", extensions: ["nya"] }],
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
if (!begin()) return;
|
||||
try {
|
||||
const path = await saveFileDialog({
|
||||
filters: [{ name: "NyaTerm Backup", extensions: ["nya"] }],
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
await invoke("export_config", { outputPath: path });
|
||||
toast.success(t("settings.exportSuccess"));
|
||||
} catch (error) {
|
||||
@@ -53,20 +85,41 @@ export function useConfigTransfer() {
|
||||
error,
|
||||
});
|
||||
toast.error(`${t("settings.exportFailed")}: ${error}`);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (runtime === "web") {
|
||||
if (!begin()) return;
|
||||
try {
|
||||
const file = await pickBrowserFile(".nya");
|
||||
if (!file) return;
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error(t("web.backupTooLarge"));
|
||||
return;
|
||||
}
|
||||
setBackupFile(file);
|
||||
setBackupError("");
|
||||
setBackupMode("import");
|
||||
} catch (error) {
|
||||
toast.error(`${t("settings.importFailed")}: ${error}`);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!ensureMasterPassword()) return;
|
||||
|
||||
const path = await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "NyaTerm Backup", extensions: ["nya"] }],
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
if (!begin()) return;
|
||||
try {
|
||||
const path = await openFileDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "NyaTerm Backup", extensions: ["nya"] }],
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
await invoke("import_config", { filePath: path });
|
||||
toast.success(t("settings.importSuccess"));
|
||||
} catch (error) {
|
||||
@@ -77,6 +130,8 @@ export function useConfigTransfer() {
|
||||
error,
|
||||
});
|
||||
toast.error(`${t("settings.importFailed")}: ${error}`);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,14 +150,21 @@ export function useConfigTransfer() {
|
||||
};
|
||||
|
||||
const handleExportDiagnostics = async () => {
|
||||
const path = await saveFileDialog({
|
||||
filters: [{ name: "NyaTerm Diagnostics", extensions: ["zip"] }],
|
||||
defaultPath: "nyaterm-diagnostics.zip",
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
if (!begin()) return;
|
||||
try {
|
||||
if (runtime === "web") {
|
||||
await logger.flush();
|
||||
await exportBrowserDiagnostics();
|
||||
toast.success(t("settings.exportDiagnosticsSuccess"));
|
||||
return;
|
||||
}
|
||||
const path = await saveFileDialog({
|
||||
filters: [{ name: "NyaTerm Diagnostics", extensions: ["zip"] }],
|
||||
defaultPath: "nyaterm-diagnostics.zip",
|
||||
});
|
||||
|
||||
if (!path) return;
|
||||
|
||||
await invoke("export_diagnostics", { outputPath: path });
|
||||
toast.success(t("settings.exportDiagnosticsSuccess"));
|
||||
} catch (error) {
|
||||
@@ -113,31 +175,72 @@ export function useConfigTransfer() {
|
||||
error,
|
||||
});
|
||||
toast.error(`${t("settings.exportDiagnosticsFailed")}: ${error}`);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
const submitBackup = async (password: string) => {
|
||||
if (!backupMode || !begin()) return;
|
||||
setBackupError("");
|
||||
try {
|
||||
if (backupMode === "import" && backupFile) await importBrowserConfig(backupFile, password);
|
||||
else if (backupMode === "export") await exportBrowserConfig(password);
|
||||
toast.success(
|
||||
t(backupMode === "import" ? "settings.importSuccess" : "settings.exportSuccess"),
|
||||
);
|
||||
closeBackup();
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
domain: "settings.persistence",
|
||||
event: `config.${backupMode}_failed`,
|
||||
message: "Browser backup operation failed",
|
||||
error,
|
||||
});
|
||||
const requestId =
|
||||
error && typeof error === "object" && "requestId" in error ? error.requestId : undefined;
|
||||
setBackupError(
|
||||
`${t(backupMode === "import" ? "web.backupImportFailed" : "settings.exportFailed")}${requestId ? ` (${requestId})` : ""}`,
|
||||
);
|
||||
} finally {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
const passwordAlert = (
|
||||
<AlertDialog open={showPasswordAlert} onOpenChange={setShowPasswordAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("settings.masterPasswordRequired")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.masterPasswordRequiredDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowPasswordAlert(false);
|
||||
openSettings("security");
|
||||
}}
|
||||
>
|
||||
{t("settings.security")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<>
|
||||
{backupMode && (
|
||||
<BrowserBackupDialog
|
||||
key={backupMode}
|
||||
mode={backupMode}
|
||||
busy={busy}
|
||||
error={backupError}
|
||||
onClose={closeBackup}
|
||||
onSubmit={submitBackup}
|
||||
/>
|
||||
)}
|
||||
<AlertDialog open={showPasswordAlert} onOpenChange={setShowPasswordAlert}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("settings.masterPasswordRequired")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.masterPasswordRequiredDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowPasswordAlert(false);
|
||||
openSettings("security");
|
||||
}}
|
||||
>
|
||||
{t("settings.security")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -146,5 +249,6 @@ export function useConfigTransfer() {
|
||||
handleOpenLogs,
|
||||
handleExportDiagnostics,
|
||||
passwordAlert,
|
||||
transferBusy: busy,
|
||||
};
|
||||
}
|
||||
+24
-1
@@ -1,10 +1,24 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import en from "./locales/en.json";
|
||||
import ko from "./locales/ko.json";
|
||||
import zhCN from "./locales/zh-CN.json";
|
||||
import zhTW from "./locales/zh-TW.json";
|
||||
|
||||
const WEB_LANGUAGE_CACHE_KEY = "nyaterm-web-language";
|
||||
const supportedLanguages = ["en", "zh-CN", "zh-TW", "ko"];
|
||||
function initialLanguage(): string {
|
||||
if (runtime !== "web") return "en";
|
||||
try {
|
||||
const cached = localStorage.getItem(WEB_LANGUAGE_CACHE_KEY);
|
||||
if (cached && supportedLanguages.includes(cached)) return cached;
|
||||
} catch {}
|
||||
const language = navigator.language.toLowerCase();
|
||||
if (language.startsWith("zh")) return /tw|hk|hant/.test(language) ? "zh-TW" : "zh-CN";
|
||||
return language.startsWith("ko") ? "ko" : "en";
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
@@ -12,13 +26,22 @@ i18n.use(initReactI18next).init({
|
||||
"zh-TW": { translation: zhTW },
|
||||
ko: { translation: ko },
|
||||
},
|
||||
lng: "en",
|
||||
lng: initialLanguage(),
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (runtime === "web")
|
||||
i18n.on("languageChanged", (language) => {
|
||||
if (supportedLanguages.includes(language)) {
|
||||
try {
|
||||
localStorage.setItem(WEB_LANGUAGE_CACHE_KEY, language);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
export const AVAILABLE_LANGUAGES = [
|
||||
{ id: "en", name: "English" },
|
||||
{ id: "zh-CN", name: "中文 (简体)" },
|
||||
|
||||
@@ -3176,13 +3176,40 @@
|
||||
"updateNow": "Update Now"
|
||||
},
|
||||
"web": {
|
||||
"backupExportDescription": "Set a backup password to restore on another Web instance or the desktop app. Keep this password safe.",
|
||||
"backupExportTitle": "Export backup",
|
||||
"backupImportDescription": "Enter the password used to create this .nya backup. Restoring replaces this instance’s portable configuration.",
|
||||
"backupImportFailed": "Restore failed. Check your backup password and file integrity.",
|
||||
"backupImportTitle": "Restore backup",
|
||||
"backupOverwriteConfirm": "I confirm replacing this instance’s settings, saved connections, credentials, quick commands, history, and notes.",
|
||||
"backupPassword": "Backup password",
|
||||
"backupPasswordConfirm": "Confirm backup password",
|
||||
"backupPasswordMismatch": "Passwords do not match.",
|
||||
"backupTooLarge": "Backup files must not exceed 50 MiB.",
|
||||
"backupWorking": "Working…",
|
||||
"checkingSession": "Checking your session…",
|
||||
"clipboardUnavailable": "Clipboard access is unavailable. Use HTTPS and allow clipboard access in your browser.",
|
||||
"connecting": "Connecting to your workspace…",
|
||||
"desktopOnly": "Desktop only",
|
||||
"eventConnectionFailed": "Signed in, but the workspace connection failed. Try reconnecting.",
|
||||
"hidePassword": "Hide password",
|
||||
"importCredentialsHint": "This format does not include restorable passwords or private keys. Add authentication details after importing.",
|
||||
"invalidPassword": "Incorrect password. Please try again.",
|
||||
"password": "Server password",
|
||||
"privateWorkspace": "Your terminal workspace",
|
||||
"proxyCommandUnavailable": "ProxyCommand is only available in the desktop app.",
|
||||
"retryConnection": "Retry connection",
|
||||
"serverUnavailable": "Unable to reach the server. Check your connection and try again.",
|
||||
"sessionTypesHint": "Web mode supports SSH, Telnet, and VNC. Use the desktop app for local terminals, serial, and RDP.",
|
||||
"showPassword": "Show password",
|
||||
"signIn": "Sign in",
|
||||
"signInDescription": "Enter your server password to continue to NyaTerm.",
|
||||
"signInFailed": "Unable to sign in. Check your password and server connection.",
|
||||
"signOut": "Sign out"
|
||||
"signInRateLimited": "Too many attempts. Please try again later.",
|
||||
"signOut": "Sign out",
|
||||
"signingIn": "Signing in…",
|
||||
"welcome": "Open your workspace",
|
||||
"windtermResourcesRequired": "This WindTerm file needs profile resources or local private keys. Import it on Desktop, then transfer a .nya backup."
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM transfer cancelled",
|
||||
|
||||
@@ -3174,13 +3174,40 @@
|
||||
"updateNow": "지금 업데이트"
|
||||
},
|
||||
"web": {
|
||||
"backupExportDescription": "다른 Web 인스턴스 또는 데스크톱 앱에서 복원할 백업 비밀번호를 설정하세요. 이 비밀번호를 안전하게 보관하세요.",
|
||||
"backupExportTitle": "백업 내보내기",
|
||||
"backupImportDescription": ".nya 백업을 만들 때 사용한 비밀번호를 입력하세요. 복원하면 이 인스턴스의 이동 가능한 설정이 교체됩니다.",
|
||||
"backupImportFailed": "복원에 실패했습니다. 백업 비밀번호와 파일 무결성을 확인하세요.",
|
||||
"backupImportTitle": "백업 복원",
|
||||
"backupOverwriteConfirm": "이 인스턴스의 설정, 저장된 연결, 자격 증명, 빠른 명령, 기록 및 메모를 교체하는 데 동의합니다.",
|
||||
"backupPassword": "백업 비밀번호",
|
||||
"backupPasswordConfirm": "백업 비밀번호 확인",
|
||||
"backupPasswordMismatch": "비밀번호가 일치하지 않습니다.",
|
||||
"backupTooLarge": "백업 파일은 50 MiB를 초과할 수 없습니다.",
|
||||
"backupWorking": "처리 중…",
|
||||
"checkingSession": "로그인 상태 확인 중…",
|
||||
"clipboardUnavailable": "클립보드에 접근할 수 없습니다. HTTPS를 사용하고 브라우저의 클립보드 접근을 허용하세요.",
|
||||
"connecting": "작업 공간 연결 중…",
|
||||
"desktopOnly": "데스크톱 전용",
|
||||
"eventConnectionFailed": "로그인했지만 작업 공간 연결에 실패했습니다. 다시 연결하세요.",
|
||||
"hidePassword": "비밀번호 숨기기",
|
||||
"importCredentialsHint": "이 형식에는 복원 가능한 비밀번호나 개인 키가 없습니다. 가져온 후 인증 정보를 추가하세요.",
|
||||
"invalidPassword": "비밀번호가 올바르지 않습니다. 다시 입력하세요.",
|
||||
"password": "서버 비밀번호",
|
||||
"privateWorkspace": "나의 터미널 작업 공간",
|
||||
"proxyCommandUnavailable": "ProxyCommand는 데스크톱 앱에서만 사용할 수 있습니다.",
|
||||
"retryConnection": "다시 연결",
|
||||
"serverUnavailable": "서버에 연결할 수 없습니다. 네트워크를 확인하고 다시 시도하세요.",
|
||||
"sessionTypesHint": "웹 모드는 SSH, Telnet 및 VNC를 지원합니다. 로컬 터미널, 직렬 포트 및 RDP는 데스크톱 앱을 사용하세요.",
|
||||
"showPassword": "비밀번호 표시",
|
||||
"signIn": "로그인",
|
||||
"signInDescription": "서버 비밀번호를 입력하여 NyaTerm을 계속 사용하세요.",
|
||||
"signInFailed": "로그인하지 못했습니다. 비밀번호와 서버 연결을 확인하세요.",
|
||||
"signOut": "로그아웃"
|
||||
"signInRateLimited": "시도 횟수가 너무 많습니다. 잠시 후 다시 시도하세요.",
|
||||
"signOut": "로그아웃",
|
||||
"signingIn": "로그인 중…",
|
||||
"welcome": "작업 공간 열기",
|
||||
"windtermResourcesRequired": "이 WindTerm 파일에는 프로필 리소스 또는 로컬 개인 키가 필요합니다. 데스크톱에서 가져온 후 .nya 백업으로 이전하세요."
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM 전송이 취소되었습니다",
|
||||
|
||||
@@ -3174,13 +3174,40 @@
|
||||
"updateNow": "立即更新"
|
||||
},
|
||||
"web": {
|
||||
"backupExportDescription": "设置备份密码,用于在其他 Web 实例或桌面端恢复。请妥善保存此密码。",
|
||||
"backupExportTitle": "导出备份",
|
||||
"backupImportDescription": "输入创建此 .nya 备份时使用的密码。恢复将替换此实例的可移植配置。",
|
||||
"backupImportFailed": "恢复失败。请检查备份密码和文件是否完整。",
|
||||
"backupImportTitle": "恢复备份",
|
||||
"backupOverwriteConfirm": "我确认替换此实例的设置、保存的连接、凭据、快捷命令、历史和笔记。",
|
||||
"backupPassword": "备份密码",
|
||||
"backupPasswordConfirm": "确认备份密码",
|
||||
"backupPasswordMismatch": "两次输入的密码不一致。",
|
||||
"backupTooLarge": "备份文件不能超过 50 MiB。",
|
||||
"backupWorking": "正在处理…",
|
||||
"checkingSession": "检查登录状态…",
|
||||
"clipboardUnavailable": "无法访问剪贴板。请使用 HTTPS 并允许浏览器访问剪贴板。",
|
||||
"connecting": "连接工作区…",
|
||||
"desktopOnly": "仅桌面端支持",
|
||||
"eventConnectionFailed": "登录成功,但工作区连接失败,请重试连接。",
|
||||
"hidePassword": "隐藏密码",
|
||||
"importCredentialsHint": "此格式不包含可恢复的密码或私钥,请在导入后补充认证信息。",
|
||||
"invalidPassword": "密码不正确,请重新输入。",
|
||||
"password": "服务端密码",
|
||||
"privateWorkspace": "你的终端工作区",
|
||||
"proxyCommandUnavailable": "ProxyCommand 仅适用于桌面版。",
|
||||
"retryConnection": "重试连接",
|
||||
"serverUnavailable": "无法连接服务器,请检查网络后重试。",
|
||||
"sessionTypesHint": "Web 模式支持 SSH、Telnet 和 VNC。本地终端、串口和 RDP 请使用桌面版。",
|
||||
"showPassword": "显示密码",
|
||||
"signIn": "登录",
|
||||
"signInDescription": "输入服务器密码,继续使用 NyaTerm。",
|
||||
"signInFailed": "登录失败,请检查密码与服务端连接。",
|
||||
"signOut": "注销"
|
||||
"signInRateLimited": "尝试过于频繁,请稍后再试。",
|
||||
"signOut": "注销",
|
||||
"signingIn": "正在登录…",
|
||||
"welcome": "进入工作区",
|
||||
"windtermResourcesRequired": "此 WindTerm 文件依赖配套配置或本机私钥。请先在桌面端导入,再通过 .nya 备份迁移。"
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM 传输已取消",
|
||||
|
||||
@@ -3174,13 +3174,40 @@
|
||||
"updateNow": "立即更新"
|
||||
},
|
||||
"web": {
|
||||
"backupExportDescription": "設定備份密碼,用於在其他 Web 執行個體或桌面端還原。請妥善保存此密碼。",
|
||||
"backupExportTitle": "匯出備份",
|
||||
"backupImportDescription": "輸入建立此 .nya 備份時使用的密碼。還原將取代此執行個體的可攜設定。",
|
||||
"backupImportFailed": "還原失敗。請檢查備份密碼和檔案是否完整。",
|
||||
"backupImportTitle": "還原備份",
|
||||
"backupOverwriteConfirm": "我確認取代此執行個體的設定、儲存的連線、認證、快捷命令、歷史和筆記。",
|
||||
"backupPassword": "備份密碼",
|
||||
"backupPasswordConfirm": "確認備份密碼",
|
||||
"backupPasswordMismatch": "兩次輸入的密碼不一致。",
|
||||
"backupTooLarge": "備份檔案不能超過 50 MiB。",
|
||||
"backupWorking": "正在處理…",
|
||||
"checkingSession": "檢查登入狀態…",
|
||||
"clipboardUnavailable": "無法存取剪貼簿。請使用 HTTPS 並允許瀏覽器存取剪貼簿。",
|
||||
"connecting": "連線工作區…",
|
||||
"desktopOnly": "僅桌面端支援",
|
||||
"eventConnectionFailed": "登入成功,但工作區連線失敗,請重試連線。",
|
||||
"hidePassword": "隱藏密碼",
|
||||
"importCredentialsHint": "此格式不包含可還原的密碼或私鑰,請在匯入後補充認證資訊。",
|
||||
"invalidPassword": "密碼不正確,請重新輸入。",
|
||||
"password": "伺服器密碼",
|
||||
"privateWorkspace": "你的終端工作區",
|
||||
"proxyCommandUnavailable": "ProxyCommand 僅適用於桌面版。",
|
||||
"retryConnection": "重試連線",
|
||||
"serverUnavailable": "無法連線伺服器,請檢查網路後重試。",
|
||||
"sessionTypesHint": "Web 模式支援 SSH、Telnet 和 VNC。本機終端、序列埠和 RDP 請使用桌面版。",
|
||||
"showPassword": "顯示密碼",
|
||||
"signIn": "登入",
|
||||
"signInDescription": "輸入伺服器密碼,繼續使用 NyaTerm。",
|
||||
"signInFailed": "登入失敗,請檢查密碼與伺服器連線。",
|
||||
"signOut": "登出"
|
||||
"signInRateLimited": "嘗試過於頻繁,請稍後再試。",
|
||||
"signOut": "登出",
|
||||
"signingIn": "正在登入…",
|
||||
"welcome": "進入工作區",
|
||||
"windtermResourcesRequired": "此 WindTerm 檔案依賴配套設定或本機私鑰。請先在桌面端匯入,再透過 .nya 備份遷移。"
|
||||
},
|
||||
"zmodem": {
|
||||
"cancelled": "ZMODEM 傳輸已取消",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), startEvents: vi.fn() }));
|
||||
vi.mock("./http", () => ({
|
||||
...mocks,
|
||||
BackendRequestError: class extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
import { BrowserGate } from "./BrowserGate";
|
||||
import { BackendRequestError } from "./http";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.authenticate
|
||||
.mockRejectedValueOnce(new BackendRequestError("required", 401))
|
||||
.mockResolvedValue(undefined);
|
||||
mocks.startEvents.mockResolvedValue(undefined);
|
||||
});
|
||||
const mount = () =>
|
||||
render(
|
||||
<BrowserGate>
|
||||
<div>Workspace</div>
|
||||
</BrowserGate>,
|
||||
);
|
||||
async function signIn() {
|
||||
await screen.findByRole("button", { name: "web.signIn" });
|
||||
fireEvent.change(screen.getByLabelText("web.password"), {
|
||||
target: { value: "fixture-password" },
|
||||
});
|
||||
fireEvent.submit(screen.getByLabelText("web.password").closest("form") as HTMLFormElement);
|
||||
}
|
||||
describe("Browser login", () => {
|
||||
it("retries the event connection without signing in again", async () => {
|
||||
mocks.startEvents.mockRejectedValueOnce(new Error("offline"));
|
||||
mount();
|
||||
await signIn();
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("web.eventConnectionFailed");
|
||||
expect(screen.queryByLabelText("web.password")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "web.retryConnection" }));
|
||||
expect(await screen.findByText("Workspace")).toBeTruthy();
|
||||
expect(mocks.authenticate).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.startEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it.each([
|
||||
[401, "web.invalidPassword"],
|
||||
[429, "web.signInRateLimited"],
|
||||
[503, "web.serverUnavailable"],
|
||||
])("distinguishes status %s", async (status, key) => {
|
||||
mocks.authenticate
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new BackendRequestError("required", 401))
|
||||
.mockRejectedValueOnce(new BackendRequestError("failed", Number(status)));
|
||||
mount();
|
||||
await signIn();
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(String(key));
|
||||
expect(screen.queryByText("Workspace")).toBeNull();
|
||||
});
|
||||
it("toggles visibility and prevents duplicate submissions during authentication", async () => {
|
||||
let resolve!: () => void;
|
||||
mocks.authenticate
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new BackendRequestError("required", 401))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "web.signIn" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "web.showPassword" }));
|
||||
expect(screen.getByLabelText("web.password").getAttribute("type")).toBe("text");
|
||||
await signIn();
|
||||
fireEvent.submit(screen.getByLabelText("web.password").closest("form") as HTMLFormElement);
|
||||
expect(mocks.authenticate).toHaveBeenCalledTimes(2);
|
||||
await act(async () => resolve());
|
||||
await waitFor(() => expect(screen.getByText("Workspace")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
+202
-53
@@ -1,72 +1,221 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Eye, EyeOff, LoaderCircle, LockKeyhole } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authenticate, startEvents } from "./http";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { authenticate, BackendRequestError, startEvents } from "./http";
|
||||
|
||||
export function BrowserGate({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [phase, setPhase] = useState<"checking" | "idle" | "signingIn" | "connecting">("checking");
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [errorKey, setErrorKey] = useState("");
|
||||
const inFlight = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const busy = phase !== "idle";
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void authenticate()
|
||||
.then(startEvents)
|
||||
.then(() => {
|
||||
if (active) setReady(true);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (active) setChecking(false);
|
||||
});
|
||||
alive.current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
await authenticate();
|
||||
if (!active) return;
|
||||
setAuthenticated(true);
|
||||
setPhase("connecting");
|
||||
try {
|
||||
await startEvents();
|
||||
if (active) setReady(true);
|
||||
} catch (error) {
|
||||
logger.warn({
|
||||
domain: "app.lifecycle",
|
||||
event: "web.events_unavailable",
|
||||
message: "Web event connection failed",
|
||||
error,
|
||||
});
|
||||
if (active) setErrorKey("web.eventConnectionFailed");
|
||||
}
|
||||
} catch (error) {
|
||||
if (active && (!(error instanceof BackendRequestError) || error.status !== 401)) {
|
||||
setErrorKey(
|
||||
error instanceof BackendRequestError && error.status === 429
|
||||
? "web.signInRateLimited"
|
||||
: "web.serverUnavailable",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (active) setPhase("idle");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (phase === "idle" && !authenticated) inputRef.current?.focus();
|
||||
}, [phase, authenticated]);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy || inFlight.current || (!authenticated && !password)) return;
|
||||
inFlight.current = true;
|
||||
setErrorKey("");
|
||||
try {
|
||||
if (!authenticated) {
|
||||
setPhase("signingIn");
|
||||
try {
|
||||
await authenticate(password);
|
||||
if (!alive.current) return;
|
||||
setPassword("");
|
||||
setAuthenticated(true);
|
||||
} catch (error) {
|
||||
logger.warn({
|
||||
domain: "security.flow",
|
||||
event: "web.sign_in_failed",
|
||||
message: "Web sign-in failed",
|
||||
error,
|
||||
});
|
||||
if (alive.current)
|
||||
setErrorKey(
|
||||
error instanceof BackendRequestError && error.status === 401
|
||||
? "web.invalidPassword"
|
||||
: error instanceof BackendRequestError && error.status === 429
|
||||
? "web.signInRateLimited"
|
||||
: "web.serverUnavailable",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setPhase("connecting");
|
||||
try {
|
||||
await startEvents();
|
||||
if (alive.current) setReady(true);
|
||||
} catch (error) {
|
||||
logger.warn({
|
||||
domain: "app.lifecycle",
|
||||
event: "web.events_unavailable",
|
||||
message: "Web event connection failed",
|
||||
error,
|
||||
});
|
||||
if (alive.current) setErrorKey("web.eventConnectionFailed");
|
||||
}
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (alive.current) setPhase("idle");
|
||||
}
|
||||
};
|
||||
|
||||
if (ready) return children;
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background text-foreground">
|
||||
<form
|
||||
className="w-80 space-y-4 rounded-lg border p-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setChecking(true);
|
||||
setError("");
|
||||
void authenticate(password)
|
||||
.then(startEvents)
|
||||
.then(() => {
|
||||
setPassword("");
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => setError(t("web.signInFailed")))
|
||||
.finally(() => setChecking(false));
|
||||
}}
|
||||
>
|
||||
<h1 className="text-xl font-semibold">NyaTerm Web</h1>
|
||||
<label className="block space-y-2">
|
||||
<span>{t("web.password")}</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
<main className="flex min-h-dvh items-center justify-center bg-background px-5 py-10 text-foreground">
|
||||
<div className="w-full max-w-[380px]">
|
||||
<div className="mb-7 flex items-center gap-3">
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}icons/app/nyaterm.svg`}
|
||||
alt=""
|
||||
className="size-10"
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={checking || !password}
|
||||
>
|
||||
{t("web.signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold tracking-tight">NyaTerm</p>
|
||||
<p className="text-xs text-muted-foreground">Web</p>
|
||||
</div>
|
||||
</div>
|
||||
<section className="rounded-xl border bg-card p-6 shadow-sm sm:p-7" aria-busy={busy}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("web.welcome")}</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
{t("web.signInDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{!authenticated && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web-password">{t("web.password")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="web-password"
|
||||
type={visible ? "text" : "password"}
|
||||
className="h-11 pr-11"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
disabled={busy}
|
||||
aria-invalid={errorKey === "web.invalidPassword"}
|
||||
aria-describedby={errorKey ? "web-login-error" : undefined}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1 size-9 text-muted-foreground"
|
||||
disabled={busy}
|
||||
aria-label={t(visible ? "web.hidePassword" : "web.showPassword")}
|
||||
aria-pressed={visible}
|
||||
onClick={() => setVisible(!visible)}
|
||||
>
|
||||
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorKey && (
|
||||
<p
|
||||
id="web-login-error"
|
||||
role="alert"
|
||||
className="text-sm leading-relaxed text-destructive"
|
||||
>
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="h-11 w-full"
|
||||
type="submit"
|
||||
disabled={busy || (!authenticated && !password)}
|
||||
>
|
||||
{busy && <LoaderCircle className="size-4 animate-spin" />}
|
||||
{t(
|
||||
phase === "checking"
|
||||
? "web.checkingSession"
|
||||
: phase === "signingIn"
|
||||
? "web.signingIn"
|
||||
: phase === "connecting"
|
||||
? "web.connecting"
|
||||
: authenticated
|
||||
? "web.retryConnection"
|
||||
: "web.signIn",
|
||||
)}
|
||||
</Button>
|
||||
{busy && (
|
||||
<output className="sr-only">
|
||||
{t(
|
||||
phase === "checking"
|
||||
? "web.checkingSession"
|
||||
: phase === "connecting"
|
||||
? "web.connecting"
|
||||
: "web.signingIn",
|
||||
)}
|
||||
</output>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
<p className="mt-5 flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
|
||||
<LockKeyhole className="size-3.5" />
|
||||
{t("web.privateWorkspace")}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { EventCallback, Options, UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { browserEmit, browserListen } from "./events";
|
||||
import { httpInvoke } from "./http";
|
||||
import { runtime } from "./runtime";
|
||||
import { tauriBackend } from "./tauri";
|
||||
import { httpInvoke } from "./http";
|
||||
import { browserEmit, browserListen } from "./events";
|
||||
|
||||
export function invoke<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
context?: { requestId?: string },
|
||||
): Promise<T> {
|
||||
return runtime === "desktop"
|
||||
? tauriBackend.invoke<T>(command, args)
|
||||
: httpInvoke<T>(command, args);
|
||||
: httpInvoke<T>(command, args, context);
|
||||
}
|
||||
export function listen<T>(
|
||||
event: string,
|
||||
@@ -24,8 +25,6 @@ export function listen<T>(
|
||||
: browserListen(event, callback);
|
||||
}
|
||||
export function emit<T>(event: string, payload?: T): Promise<void> {
|
||||
return runtime === "desktop"
|
||||
? tauriBackend.emit(event, payload)
|
||||
: browserEmit(event, payload);
|
||||
return runtime === "desktop" ? tauriBackend.emit(event, payload) : browserEmit(event, payload);
|
||||
}
|
||||
export type { Event, EventCallback, UnlistenFn } from "@tauri-apps/api/event";
|
||||
@@ -3,10 +3,29 @@ export function pickBrowserFile(accept: string): Promise<File | null> {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = accept;
|
||||
return new Promise((resolve) => {
|
||||
input.onchange = () => resolve(input.files?.[0] ?? null);
|
||||
input.oncancel = () => resolve(null);
|
||||
input.click();
|
||||
input.hidden = true;
|
||||
document.body.append(input);
|
||||
return new Promise((resolve, reject) => {
|
||||
let focusTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (file: File | null) => {
|
||||
if (focusTimer) clearTimeout(focusTimer);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
input.remove();
|
||||
resolve(file);
|
||||
};
|
||||
const onFocus = () => {
|
||||
focusTimer = setTimeout(() => finish(input.files?.[0] ?? null), 400);
|
||||
};
|
||||
if (!("oncancel" in input)) window.addEventListener("focus", onFocus, { once: true });
|
||||
input.onchange = () => finish(input.files?.[0] ?? null);
|
||||
input.oncancel = () => finish(null);
|
||||
try {
|
||||
input.click();
|
||||
} catch (error) {
|
||||
window.removeEventListener("focus", onFocus);
|
||||
input.remove();
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,9 +39,12 @@ export function downloadBlob(name: string, blob: Blob): void {
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
try {
|
||||
link.click();
|
||||
} finally {
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readBrowserJson<T>(file: File): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Authentication-aware transport, kept separate to avoid logger / HTTP recursion. */
|
||||
type LogSender = (entries: unknown[], keepalive: boolean) => Promise<void>;
|
||||
let sender: LogSender | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
export function configureBrowserLogs(next: LogSender | undefined): void {
|
||||
sender = next;
|
||||
if (sender) for (const listener of listeners) listener();
|
||||
}
|
||||
export function browserLogsReady(): boolean {
|
||||
return !!sender;
|
||||
}
|
||||
export function onBrowserLogsReady(listener: () => void): void {
|
||||
listeners.add(listener);
|
||||
}
|
||||
export async function sendBrowserLogs(entries: unknown[], keepalive = false): Promise<void> {
|
||||
if (!sender) throw new Error("Browser log transport is not authenticated");
|
||||
await sender(entries, keepalive);
|
||||
}
|
||||
|
||||
export function createBrowserRequestId(): string {
|
||||
const bytes = 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")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { downloadBlob } from "./browserArtifacts";
|
||||
import { createBrowserRequestId } from "./browserDiagnostics";
|
||||
import { BackendRequestError, csrfHeader } from "./http";
|
||||
import { backendURL } from "./runtime";
|
||||
|
||||
export async function artifactRequest(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const requestId = createBrowserRequestId();
|
||||
const response = await fetch(backendURL(path), {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-Nyaterm-Request": "1",
|
||||
"X-Nyaterm-Request-Id": requestId,
|
||||
...csrfHeader(),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const value = await response.json().catch(() => ({}));
|
||||
throw new BackendRequestError(
|
||||
value.error ?? `Backend request failed (${response.status})`,
|
||||
response.status,
|
||||
response.headers.get("X-Nyaterm-Request-Id") ?? requestId,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
export async function exportBrowserConfig(password: string): Promise<void> {
|
||||
const response = await artifactRequest("api/backups/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
downloadBlob("nyaterm-backup.nya", await response.blob());
|
||||
}
|
||||
export async function importBrowserConfig(file: File, password: string): Promise<void> {
|
||||
if (file.size > 50 * 1024 * 1024) throw new Error("Backup exceeds 50 MiB");
|
||||
const body = new FormData();
|
||||
body.append("password", password);
|
||||
body.append("file", file);
|
||||
await artifactRequest("api/backups/import", { method: "POST", body });
|
||||
}
|
||||
export async function importBrowserConnections(
|
||||
source: string,
|
||||
file: File,
|
||||
): Promise<{ imported: number; warnings: string[] }> {
|
||||
if (file.size > 10 * 1024 * 1024) throw new Error("Import exceeds 10 MiB");
|
||||
const body = new FormData();
|
||||
body.append("source", source);
|
||||
body.append("file", file);
|
||||
return (await artifactRequest("api/imports/connections", { method: "POST", body })).json();
|
||||
}
|
||||
export async function exportBrowserDiagnostics(): Promise<void> {
|
||||
downloadBlob(
|
||||
"nyaterm-diagnostics.zip",
|
||||
await (await artifactRequest("api/diagnostics/export")).blob(),
|
||||
);
|
||||
}
|
||||
+30
-24
@@ -1,5 +1,7 @@
|
||||
import { backendURL, runtime, requireCapability } from "./runtime";
|
||||
import { csrfHeader } from "./http";
|
||||
import { downloadBlob } from "./browserArtifacts";
|
||||
import { createBrowserRequestId } from "./browserDiagnostics";
|
||||
import { BackendRequestError, csrfHeader } from "./http";
|
||||
import { backendURL, requireCapability, runtime } from "./runtime";
|
||||
|
||||
export interface BrowserUploadResult {
|
||||
bytes: number;
|
||||
@@ -12,43 +14,47 @@ export async function uploadBrowserFile(
|
||||
path: string,
|
||||
file: Blob,
|
||||
): Promise<BrowserUploadResult> {
|
||||
const url = backendURL(
|
||||
`api/sessions/${encodeURIComponent(sessionId)}/upload`,
|
||||
);
|
||||
const url = backendURL(`api/sessions/${encodeURIComponent(sessionId)}/upload`);
|
||||
url.searchParams.set("path", path);
|
||||
const requestId = createBrowserRequestId();
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { ...csrfHeader(), "Content-Type": "application/octet-stream" },
|
||||
headers: {
|
||||
...csrfHeader(),
|
||||
"X-Nyaterm-Request-Id": requestId,
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error ?? "Upload failed");
|
||||
if (!response.ok)
|
||||
throw new BackendRequestError(
|
||||
result.error ?? "Upload failed",
|
||||
response.status,
|
||||
response.headers.get("X-Nyaterm-Request-Id") ?? requestId,
|
||||
);
|
||||
return result as BrowserUploadResult;
|
||||
}
|
||||
|
||||
export async function downloadBrowserFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
export async function downloadBrowserFile(sessionId: string, path: string): Promise<void> {
|
||||
requireCapability("browserFiles");
|
||||
const url = backendURL(
|
||||
`api/sessions/${encodeURIComponent(sessionId)}/download`,
|
||||
);
|
||||
const url = backendURL(`api/sessions/${encodeURIComponent(sessionId)}/download`);
|
||||
url.searchParams.set("path", path);
|
||||
const link = document.createElement("a");
|
||||
const response = await fetch(url, { credentials: "same-origin" });
|
||||
const requestId = createBrowserRequestId();
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Nyaterm-Request-Id": requestId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
throw new Error(result.error ?? "Download failed");
|
||||
throw new BackendRequestError(
|
||||
result.error ?? "Download failed",
|
||||
response.status,
|
||||
response.headers.get("X-Nyaterm-Request-Id") ?? requestId,
|
||||
);
|
||||
}
|
||||
const objectURL = URL.createObjectURL(await response.blob());
|
||||
link.href = objectURL;
|
||||
link.download = path.split("/").pop() ?? "download";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000);
|
||||
downloadBlob(path.split("/").pop() ?? "download", await response.blob());
|
||||
}
|
||||
export async function uploadBrowserFiles(
|
||||
sessionId: string,
|
||||
|
||||
+54
-26
@@ -1,21 +1,22 @@
|
||||
import { backendURL, requireCapability } from "./runtime";
|
||||
import { configureBrowserLogs, createBrowserRequestId } from "./browserDiagnostics";
|
||||
import { deliver } from "./events";
|
||||
import { BrowserTerminals } from "./terminal";
|
||||
import { uploadBrowserFile } from "./files";
|
||||
import { closeBrowserVnc, closeAllBrowserVnc, sendBrowserVnc } from "./vnc";
|
||||
import { backendURL, requireCapability } from "./runtime";
|
||||
import { BrowserTerminals } from "./terminal";
|
||||
import { closeAllBrowserVnc, closeBrowserVnc, sendBrowserVnc } from "./vnc";
|
||||
|
||||
let csrf: string | undefined;
|
||||
let source: EventSource | undefined;
|
||||
let eventReady: Promise<void> | undefined;
|
||||
const terminals = new BrowserTerminals(
|
||||
(id, signal) =>
|
||||
request("api/commands/get_session_info", { sessionId: id }, "POST", signal),
|
||||
(id, signal) => request("api/commands/get_session_info", { sessionId: id }, "POST", signal),
|
||||
(id) => request("api/commands/close_session", { sessionId: id }),
|
||||
);
|
||||
export class BackendRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export async function request<T>(
|
||||
body?: unknown,
|
||||
method = body === undefined ? "GET" : "POST",
|
||||
signal?: AbortSignal,
|
||||
requestId = createBrowserRequestId(),
|
||||
): Promise<T> {
|
||||
const response = await fetch(backendURL(path), {
|
||||
method,
|
||||
@@ -36,6 +38,7 @@ export async function request<T>(
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Nyaterm-Request": "1",
|
||||
"X-Nyaterm-Request-Id": requestId,
|
||||
...(csrf ? { "X-Nyaterm-Csrf": csrf } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
@@ -45,6 +48,7 @@ export async function request<T>(
|
||||
throw new BackendRequestError(
|
||||
value.error ?? `Backend request failed (${response.status})`,
|
||||
response.status,
|
||||
response.headers.get("X-Nyaterm-Request-Id") ?? requestId,
|
||||
);
|
||||
return value as T;
|
||||
}
|
||||
@@ -54,6 +58,22 @@ export async function authenticate(password?: string): Promise<void> {
|
||||
password === undefined ? undefined : { password },
|
||||
);
|
||||
csrf = value.csrf;
|
||||
configureBrowserLogs(async (entries, keepalive) => {
|
||||
const response = await fetch(backendURL("api/logs/frontend"), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
keepalive,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Nyaterm-Request": "1",
|
||||
"X-Nyaterm-Request-Id": createBrowserRequestId(),
|
||||
...csrfHeader(),
|
||||
},
|
||||
body: JSON.stringify({ entries }),
|
||||
});
|
||||
if (response.status === 401) configureBrowserLogs(undefined);
|
||||
if (!response.ok) throw new BackendRequestError("Log upload failed", response.status);
|
||||
});
|
||||
}
|
||||
export function startEvents(): Promise<void> {
|
||||
if (eventReady) return eventReady;
|
||||
@@ -65,6 +85,7 @@ export function startEvents(): Promise<void> {
|
||||
reject(new Error("Backend event stream unavailable"));
|
||||
eventReady = undefined;
|
||||
source?.close();
|
||||
source = undefined;
|
||||
}, 10_000);
|
||||
source.onmessage = ({ data }) => {
|
||||
const event = JSON.parse(data) as { event: string; payload: unknown };
|
||||
@@ -93,11 +114,11 @@ export function startEvents(): Promise<void> {
|
||||
export async function httpInvoke<T>(
|
||||
command: string,
|
||||
args: Record<string, unknown> = {},
|
||||
context?: { requestId?: string },
|
||||
): Promise<T> {
|
||||
if (command === "get_default_local_shell") requireCapability("localShell");
|
||||
if (nativeCommands.test(command)) requireCapability("nativeFiles");
|
||||
if (command === "read_clipboard_text")
|
||||
return (await navigator.clipboard.readText()) as T;
|
||||
if (command === "read_clipboard_text") return (await navigator.clipboard.readText()) as T;
|
||||
if (command === "write_clipboard_text") {
|
||||
await navigator.clipboard.writeText(String(args.text));
|
||||
return undefined as T;
|
||||
@@ -109,8 +130,7 @@ export async function httpInvoke<T>(
|
||||
const item = items.find((entry) => entry.types.includes("image/png"));
|
||||
if (!item) return null as T;
|
||||
const image = await item.getType("image/png");
|
||||
if (image.size > 10 * 1024 * 1024)
|
||||
throw new Error("Clipboard image exceeds 10 MiB");
|
||||
if (image.size > 10 * 1024 * 1024) throw new Error("Clipboard image exceeds 10 MiB");
|
||||
const sessionId = String(args.sessionId);
|
||||
const directory =
|
||||
typeof args.remoteDir === "string"
|
||||
@@ -118,9 +138,7 @@ export async function httpInvoke<T>(
|
||||
: await request<string>("api/commands/get_home_dir", { sessionId });
|
||||
const path = `${directory.replace(/\/$/, "")}/nyaterm-clipboard-${crypto.randomUUID()}.png`;
|
||||
const result = await uploadBrowserFile(sessionId, path, image);
|
||||
return (
|
||||
result.status === "skipped" ? null : { remote_path: result.path }
|
||||
) as T;
|
||||
return (result.status === "skipped" ? null : { remote_path: result.path }) as T;
|
||||
}
|
||||
if (command === "read_clipboard_file_paths") return [] as T;
|
||||
if (
|
||||
@@ -140,6 +158,7 @@ export async function httpInvoke<T>(
|
||||
case "quit_application": {
|
||||
terminals.stopAll();
|
||||
await request("api/auth/logout", {});
|
||||
configureBrowserLogs(undefined);
|
||||
source?.close();
|
||||
closeAllBrowserVnc();
|
||||
window.location.reload();
|
||||
@@ -150,25 +169,28 @@ export async function httpInvoke<T>(
|
||||
case "create_telnet_session":
|
||||
case "create_vnc_session": {
|
||||
await startEvents();
|
||||
const result = await request<{ session_id: string }>("api/sessions", {
|
||||
...args,
|
||||
type:
|
||||
command === "create_telnet_session"
|
||||
? "telnet"
|
||||
: command === "create_vnc_session"
|
||||
? "vnc"
|
||||
: "ssh",
|
||||
});
|
||||
const result = await request<{ session_id: string }>(
|
||||
"api/sessions",
|
||||
{
|
||||
...args,
|
||||
type:
|
||||
command === "create_telnet_session"
|
||||
? "telnet"
|
||||
: command === "create_vnc_session"
|
||||
? "vnc"
|
||||
: "ssh",
|
||||
},
|
||||
"POST",
|
||||
undefined,
|
||||
context?.requestId,
|
||||
);
|
||||
return result.session_id as T;
|
||||
}
|
||||
case "attach_session":
|
||||
await terminals.attach(id);
|
||||
return undefined as T;
|
||||
case "write_to_session":
|
||||
await terminals.send(
|
||||
id,
|
||||
JSON.stringify({ type: "input", data: args.data }),
|
||||
);
|
||||
await terminals.send(id, JSON.stringify({ type: "input", data: args.data }));
|
||||
return undefined as T;
|
||||
case "write_bytes_to_session":
|
||||
await terminals.send(id, new Uint8Array(args.data as number[]));
|
||||
@@ -193,7 +215,13 @@ export async function httpInvoke<T>(
|
||||
break;
|
||||
}
|
||||
}
|
||||
return request<T>(`api/commands/${encodeURIComponent(command)}`, args);
|
||||
return request<T>(
|
||||
`api/commands/${encodeURIComponent(command)}`,
|
||||
args,
|
||||
"POST",
|
||||
undefined,
|
||||
context?.requestId,
|
||||
);
|
||||
}
|
||||
export function csrfHeader(): Record<string, string> {
|
||||
return csrf ? { "X-Nyaterm-Csrf": csrf } : {};
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Pr
|
||||
args: argsSummary,
|
||||
},
|
||||
});
|
||||
const result = await tauriInvoke<T>(cmd, args);
|
||||
const result = await tauriInvoke<T>(cmd, args, { requestId });
|
||||
logger.debug({
|
||||
domain: "tauri.invoke",
|
||||
event: "command.success",
|
||||
|
||||
+95
-33
@@ -1,4 +1,10 @@
|
||||
import { invoke as tauriInvoke } from "@/lib/backend/api";
|
||||
import {
|
||||
browserLogsReady,
|
||||
createBrowserRequestId,
|
||||
onBrowserLogsReady,
|
||||
sendBrowserLogs,
|
||||
} from "@/lib/backend/browserDiagnostics";
|
||||
import { runtime } from "@/lib/backend/runtime";
|
||||
import type { DiagnosticsLogLevel } from "@/types/global";
|
||||
|
||||
@@ -21,12 +27,7 @@ type LogDomain =
|
||||
| "background-image"
|
||||
| "updater.flow";
|
||||
|
||||
type StableLogIdKey =
|
||||
| "session_id"
|
||||
| "connection_id"
|
||||
| "transfer_id"
|
||||
| "tunnel_id"
|
||||
| "request_id";
|
||||
type StableLogIdKey = "session_id" | "connection_id" | "transfer_id" | "tunnel_id" | "request_id";
|
||||
|
||||
type LogIds = Partial<Record<StableLogIdKey, string>>;
|
||||
|
||||
@@ -60,9 +61,10 @@ const LOG_QUEUE_DROP_BATCH = 250;
|
||||
let minLevel: LogLevel = DEFAULT_LEVEL;
|
||||
const queue: FrontendLogEntry[] = [];
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let flushInFlight = false;
|
||||
let flushInFlight: Promise<void> | undefined;
|
||||
let lifecycleFlushRegistered = false;
|
||||
let droppedLogEntryCount = 0;
|
||||
let webFlushFailures = 0;
|
||||
|
||||
export function setLoggerLevel(level: DiagnosticsLogLevel): void {
|
||||
minLevel = level;
|
||||
@@ -73,6 +75,7 @@ function shouldLog(level: LogLevel): boolean {
|
||||
}
|
||||
|
||||
function createRequestId(): string {
|
||||
if (runtime === "web") return createBrowserRequestId();
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
@@ -94,6 +97,10 @@ function normalizeError(error: unknown): unknown {
|
||||
name: error.name,
|
||||
message_hash: hashString(error.message),
|
||||
stack_hash: error.stack ? hashString(error.stack) : undefined,
|
||||
...("status" in error && typeof error.status === "number" ? { status: error.status } : {}),
|
||||
...("requestId" in error && typeof error.requestId === "string"
|
||||
? { request_id: error.requestId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return error;
|
||||
@@ -150,20 +157,12 @@ function isHostKey(key: string): boolean {
|
||||
|
||||
function isUsernameKey(key: string): boolean {
|
||||
const normalized = normalizeKey(key);
|
||||
return (
|
||||
normalized === "username" ||
|
||||
normalized === "user" ||
|
||||
normalized.endsWith("_username")
|
||||
);
|
||||
return normalized === "username" || normalized === "user" || normalized.endsWith("_username");
|
||||
}
|
||||
|
||||
function isPathKey(key: string): boolean {
|
||||
const normalized = normalizeKey(key);
|
||||
return (
|
||||
normalized === "path" ||
|
||||
normalized === "cwd" ||
|
||||
normalized.endsWith("_path")
|
||||
);
|
||||
return normalized === "path" || normalized === "cwd" || normalized.endsWith("_path");
|
||||
}
|
||||
|
||||
function sanitizeString(key: string | undefined, value: string): unknown {
|
||||
@@ -284,32 +283,67 @@ function prependQueueOverflowSummary(): void {
|
||||
queue.unshift(summary);
|
||||
}
|
||||
|
||||
async function flushQueue(): Promise<void> {
|
||||
if (flushInFlight) return;
|
||||
function flushQueue(): Promise<void> {
|
||||
if (flushInFlight) return flushInFlight;
|
||||
flushInFlight = flushBatch().finally(() => {
|
||||
flushInFlight = undefined;
|
||||
if (!queue.length || (runtime === "web" && !browserLogsReady())) return;
|
||||
if (runtime !== "web" && queue.length >= MAX_BATCH_SIZE) void flushQueue();
|
||||
else if (!flushTimer)
|
||||
flushTimer = setTimeout(
|
||||
() => {
|
||||
flushTimer = null;
|
||||
void flushQueue();
|
||||
},
|
||||
webFlushFailures ? 1000 * 2 ** (webFlushFailures - 1) : BATCH_DELAY_MS,
|
||||
);
|
||||
});
|
||||
return flushInFlight;
|
||||
}
|
||||
|
||||
async function flushBatch(): Promise<void> {
|
||||
if (runtime === "web" && !browserLogsReady()) return;
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (queue.length === 0) return;
|
||||
|
||||
flushInFlight = true;
|
||||
prependQueueOverflowSummary();
|
||||
const batch = queue.splice(0, MAX_BATCH_SIZE);
|
||||
const batch: FrontendLogEntry[] = [];
|
||||
let batchBytes = 32;
|
||||
while (queue.length && batch.length < MAX_BATCH_SIZE) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(queue[0])).length + 1;
|
||||
if (runtime === "web" && bytes > 48 * 1024) {
|
||||
queue.shift();
|
||||
droppedLogEntryCount++;
|
||||
continue;
|
||||
}
|
||||
if (runtime === "web" && batchBytes + bytes > 48 * 1024) break;
|
||||
batch.push(queue.shift() as FrontendLogEntry);
|
||||
batchBytes += bytes;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauriInvoke("append_frontend_logs", { entries: batch });
|
||||
if (runtime === "web") {
|
||||
await sendBrowserLogs(batch, document.visibilityState === "hidden");
|
||||
webFlushFailures = 0;
|
||||
} else await tauriInvoke("append_frontend_logs", { entries: batch });
|
||||
} catch (error) {
|
||||
if (runtime === "web") {
|
||||
webFlushFailures++;
|
||||
if (webFlushFailures <= 2) queue.unshift(...batch);
|
||||
else {
|
||||
droppedLogEntryCount += batch.length;
|
||||
webFlushFailures = 0;
|
||||
}
|
||||
enforceQueueLimit();
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`[${formatTimestamp()}] [ERROR] [ui.error/logger.flush_failed] Failed to persist frontend log batch`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
flushInFlight = false;
|
||||
if (queue.length >= MAX_BATCH_SIZE) {
|
||||
void flushQueue();
|
||||
} else if (queue.length > 0) {
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +374,6 @@ function emit(level: LogLevel, payload: LogPayload): void {
|
||||
|
||||
const entry = normalizePayload(level, payload);
|
||||
writeConsole(entry);
|
||||
if (runtime === "web") return;
|
||||
queue.push(entry);
|
||||
enforceQueueLimit();
|
||||
|
||||
@@ -368,11 +401,40 @@ export const logger = {
|
||||
emit("error", payload);
|
||||
},
|
||||
|
||||
flush(): Promise<void> {
|
||||
return flushQueue();
|
||||
async flush(): Promise<void> {
|
||||
// A diagnostic export waits for queued batches, including one already in flight.
|
||||
// Bound the drain so continuous logging or an offline transport cannot hold the UI.
|
||||
for (let count = 0; count < 24; count++) {
|
||||
await flushQueue();
|
||||
if (!queue.length || webFlushFailures || (runtime === "web" && !browserLogsReady())) break;
|
||||
}
|
||||
},
|
||||
|
||||
createRequestId,
|
||||
};
|
||||
|
||||
if (runtime === "desktop") registerLifecycleFlush();
|
||||
registerLifecycleFlush();
|
||||
if (runtime === "web")
|
||||
onBrowserLogsReady(() => {
|
||||
void flushQueue();
|
||||
});
|
||||
|
||||
export function installBrowserErrorLogging(): void {
|
||||
if (runtime !== "web") return;
|
||||
window.addEventListener("error", (event) => {
|
||||
logger.error({
|
||||
domain: "ui.error",
|
||||
event: "browser.uncaught_error",
|
||||
message: "Uncaught browser error",
|
||||
error: event.error ?? new Error(event.message),
|
||||
});
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
logger.error({
|
||||
domain: "ui.error",
|
||||
event: "browser.unhandled_rejection",
|
||||
message: "Unhandled browser promise rejection",
|
||||
error: event.reason instanceof Error ? event.reason : new Error(String(event.reason)),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/backend/runtime", () => ({ runtime: "web" }));
|
||||
vi.mock("@/lib/backend/api", () => ({ invoke: vi.fn() }));
|
||||
|
||||
describe("browser diagnostic transport", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
for (const method of ["info", "warn", "error", "debug"] as const)
|
||||
vi.spyOn(console, method).mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("holds entries before authentication and drains all batches for diagnostics", async () => {
|
||||
const { logger } = await import("./logger");
|
||||
const { configureBrowserLogs } = await import("./backend/browserDiagnostics");
|
||||
for (let index = 0; index < 130; index++)
|
||||
logger.info({
|
||||
domain: "app.lifecycle",
|
||||
event: "fixture.entry",
|
||||
message: "Fixture",
|
||||
ids: { request_id: logger.createRequestId() },
|
||||
});
|
||||
await logger.flush();
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
configureBrowserLogs(send);
|
||||
await logger.flush();
|
||||
expect(send.mock.calls.flatMap(([entries]) => entries)).toHaveLength(130);
|
||||
expect(send.mock.calls.every(([entries]) => entries.length <= 50)).toBe(true);
|
||||
expect(send.mock.calls[0][0][0].ids.request_id).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("waits for an upload already in flight and bounds stalled queues", async () => {
|
||||
const { logger } = await import("./logger");
|
||||
const { configureBrowserLogs } = await import("./backend/browserDiagnostics");
|
||||
let release!: () => void;
|
||||
const send = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
configureBrowserLogs(send);
|
||||
await logger.flush();
|
||||
for (let index = 0; index < 1200; index++)
|
||||
logger.info({ domain: "app.lifecycle", event: "fixture.entry", message: "Fixture" });
|
||||
let completed = false;
|
||||
const flush = logger.flush().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
release();
|
||||
await flush;
|
||||
const entries = send.mock.calls.flatMap(([batch]) => batch);
|
||||
expect(entries.length).toBeLessThan(1200);
|
||||
expect(entries.some((entry) => entry.event === "logger.queue_overflow")).toBe(true);
|
||||
});
|
||||
|
||||
it("retries offline batches twice with backoff and avoids recursive failure logs", async () => {
|
||||
const { logger } = await import("./logger");
|
||||
const { configureBrowserLogs } = await import("./backend/browserDiagnostics");
|
||||
const send = vi.fn().mockRejectedValue(new Error("offline"));
|
||||
configureBrowserLogs(send);
|
||||
logger.error({
|
||||
domain: "ui.error",
|
||||
event: "fixture.failure",
|
||||
message: "Fixture",
|
||||
data: { password: "secret" },
|
||||
});
|
||||
await logger.flush();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(send).toHaveBeenCalledTimes(3);
|
||||
expect(console.error).toHaveBeenCalledTimes(1);
|
||||
send.mockResolvedValue(undefined);
|
||||
logger.info({ domain: "app.lifecycle", event: "fixture.recovered", message: "Recovered" });
|
||||
await logger.flush();
|
||||
expect(
|
||||
send.mock.calls[send.mock.calls.length - 1]?.[0].some(
|
||||
(entry: { event: string }) => entry.event === "logger.queue_overflow",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(send.mock.calls)).not.toContain('"password":"secret"');
|
||||
});
|
||||
});
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { runtime } from "./lib/backend/runtime";
|
||||
import { BrowserGate } from "./lib/backend/BrowserGate";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserGate } from "./lib/backend/BrowserGate";
|
||||
import { runtime } from "./lib/backend/runtime";
|
||||
import "@fontsource/jetbrains-mono/400.css";
|
||||
import "@fontsource/jetbrains-mono/500.css";
|
||||
import "@fontsource/jetbrains-mono/700.css";
|
||||
@@ -10,9 +10,9 @@ import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
import "@fontsource-variable/noto-sans-sc";
|
||||
import "./index.css";
|
||||
import { PluginProvider } from "./context/PluginContext";
|
||||
import { PluginApprovalHost } from "./components/plugins/PluginApprovalHost";
|
||||
import { PluginCommandHost } from "./components/plugins/PluginCommandHost";
|
||||
import { PluginProvider } from "./context/PluginContext";
|
||||
import {
|
||||
applyThemeToDOM,
|
||||
THEME_CACHE_KEY,
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
signalChildWindowLoadFailed,
|
||||
signalChildWindowLoadStarted,
|
||||
} from "./lib/childWindowLifecycle";
|
||||
import { installBrowserErrorLogging } from "./lib/logger";
|
||||
import { DEFAULT_THEME_ID, themes } from "./lib/themes";
|
||||
import { installWebviewReloadGuard } from "./lib/webviewReloadGuard";
|
||||
|
||||
@@ -45,6 +46,7 @@ try {
|
||||
} catch {}
|
||||
|
||||
installWebviewReloadGuard();
|
||||
installBrowserErrorLogging();
|
||||
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -56,9 +58,7 @@ if (windowType) {
|
||||
// Child window: lightweight provider stack, no full App
|
||||
// These entry points are independent and should load in parallel; serial awaits would add an
|
||||
// unnecessary chunk round trip to every child-window open.
|
||||
const childRoot = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
const childRoot = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
|
||||
// Commit an inline-background loading shell before loading provider and page chunks. This lets
|
||||
// the parent reveal a stable surface without reintroducing the macOS white or empty window.
|
||||
childRoot.render(
|
||||
|
||||
Reference in new issue
Block a user