release: 发布 v1.12.0 分享审计保留与清理完善版本 (#103)

## 版本内容

### 分享审计保留与清理
- 审计记录保留与清理能力:终态会话由所有者整体清空(写入墓碑事件保留追责线索)或导出完整 JSON 归档;终态满默认 90 天由 alarm
自动清理
- 审计保留期创建时自定义(7–365 天,白名单校验,默认 90)
-
清理留痕集中展示:清理时间与方式(手动/自动)同步用户库(`audit_purged_at`/`audit_purge_type`),折叠面板展示;已清理分享等同删除效果,不再提供查看审计入口
- 手动清空后取消已排期的自动清理闹钟;保留期服务端校验统一为 7–365 天;修复闹钟设置失败导致"有排期无闹钟"的幽灵状态

### 工程与质量
- 新增审计清理回归测试 8 用例(墓碑隔离、归属/终态校验、同步失败容错、排期回滚、留痕接缝、迁移幂等)
- UserDBDO SQL 查询类型化封装(`query<T>`/`one<T>`),消除 22 处 `as unknown as`
- 测试类型检查纳入门禁:`tests/` 拆分
`tsconfig.worker`/`tsconfig.frontend`(workers-types 与 DOM lib 的 Element
接口冲突无法单配置共存)
- 弱随机数加固(会话名 `crypto.randomUUID`);pnpm
供应链策略(`blockExoticSubdeps`/`minimumReleaseAge` 7 天/`no-downgrade`);CI
权限收敛 `contents: read`;`.gitleaks.toml` 误报豁免
- pi-lens 项目策略口径(AGENTS.md #31):XSS 类规则刻意不做项目级禁用,仅行内逐处豁免

## 提交列表

- 1c38c7a `release: 发布 v1.12.0 分享审计保留与清理完善版本`
- 4dd7c63 `feat: 审计清理回归测试、UserDBDO 类型化查询与测试类型门禁`
- 50389f3 `fix: 消除 pi-lens 剩余告警——SAFETY 注释/测试类型/XSS 抑制/空 catch 说明`
- 23a3e8e `chore: 代码质量审查整改——弱随机数/测试类型检查/供应链加固等 7 项`
- 1cf0ef9 `chore: 配置 pi-lens 项目策略降低风格类噪音并清理用户库遗留项`
- 0f27a50 `fix: 审计清理后完全隐藏分享卡片,仅保留集中式清理记录`
- 7c42e8e `feat: 审计清理改为删除效果并新增集中式清理记录留痕区`
- a3847ee `fix: 审计清理后改用折叠面板展示清理记录并补齐中英文词条`
- b730219 `fix: 审计保留期服务端校验统一为 7–365 天并修复清理闹钟静默失败`
- a68a6c8 `feat: 分享审计保留期支持创建时自定义(7–365 天)`
- 158b205 `fix: 手动清空审计后取消已排期的自动清理唤醒`
- 618f63c `feat: 分享审计记录保留与清理能力(清空/导出/自动保留期)`

## 验证结果

- typecheck 全绿(src + tests.worker + tests.frontend + frontend 四段)
- 全量测试 644/644(54 文件)
- build:frontend 构建成功并内联
- 关联 Issue/PR:无(基于 v1.11.0 #102)

---------

Co-authored-by: TanXin <tanxin@example.com>
This commit is contained in:
TanXinandTanXin authored and GitHub committed 2026-08-26 12:35:58 +08:00
1 parent 2dc1f5f290
commit 52fec4e00f
43 files changed
+1305 -305

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ jobs:
deploy:
if: github.repository_owner == 'newbietan'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+14
View File
@@ -0,0 +1,14 @@
# gitleaks 配置:豁免已核实的误报
#
# 1. README** 的 sealed_token 是 star-history.com 公开图表服务的 URL 参数
# (公开仓库统计图,非凭据;token 由该第三方服务签发并公开在文档中)。
# 2. tests/ssh/fixtures/ 下的私钥是公开协议测试夹具——AGENTS.md 明确声明
# "私钥只用于公开协议测试,绝不可用于真实服务器",与仓库一同公开。
# 3. tests/worker/session-reattach.test.ts 中的 resumeToken 是测试假数据。
[allowlist]
description = "CloudSSH 已核实误报豁免"
paths = [
'''^(?i)README(_en)?\.md$''',
'''tests/ssh/fixtures/''',
'''tests/worker/session-reattach\.test\.ts''',
]
+54
View File
@@ -0,0 +1,54 @@
{
"format": { "enabled": false },
"autofix": { "enabled": false },
"ignore": [
"tests/ssh/fixtures/**",
"README.md",
"README_en.md",
"src/worker/html.ts",
"docs/theme-editor/**"
],
"rules": {
"code-style": {
"disable": [
"lint/style/useBlockStatements",
"lint/style/noNestedTernary",
"lint/style/noNegationElse",
"lint/style/useTemplate",
"lint/style/useCollapsedElseIf",
"lint/complexity/noExcessiveCognitiveComplexity",
"lint/complexity/noForEach",
"lint/complexity/useOptionalChain",
"lint/complexity/noAdjacentSpacesInRegex",
"lint/suspicious/noExplicitAny",
"lint/suspicious/noEmptyInterface",
"lint/performance/noAccumulatingSpread",
"lint/performance/noBarrelFile",
"mixed-async-styles",
"high-complexity",
"missing-error-propagation",
"console-statement",
"ast-grep:large-class",
"ast-grep:nested-ternary",
"ast-grep:prefer-query-selector",
"ast-grep:prefer-dom-node-append",
"ast-grep:ts-redundant-filter-map",
"ast-grep:ts-parseint-no-radix",
"ast-grep:prefer-number-properties",
"ast-grep:find-import-file-without-extension",
"ast-grep:no-array-reverse-mutation",
"ast-grep:no-non-null-assertion",
"ast-grep:no-any-type",
"ast-grep:no-as-any",
"ast-grep:no-runtime-typeof",
"ast-grep:no-unsafe-dictionary-unknown",
"ast-grep:no-unknown-parameters",
"ast-grep:prefer-at",
"ast-grep:ts-double-negation",
"ast-grep:no-console-except-error",
"ast-grep:no-shape-in-symbol-names",
"ast-grep:no-open-redirect"
]
}
}
}
+6 -1
View File
@@ -7,6 +7,7 @@
- package.json (依赖、脚本命令)
- src/types.ts (Env 接口、类型定义)
- biome.json (代码格式与 lint 约定)
- .pi-lens.json (pi-lens 项目策略:规则禁用与豁免口径,见 #31)
-->
## Project Overview
@@ -162,6 +163,8 @@ pnpm run verify
cd frontend && pnpm install
```
> **供应链策略约束**:`pnpm-workspace.yaml` 已启用 `blockExoticSubdeps` / `minimumReleaseAge: 10080`(7 天)/ `trustPolicy: no-downgrade`。frozen-lockfile 部署不受影响;但未来**升级依赖到刚发布(<7 天)的新版本会安装失败**(`minimumReleaseAge` 拦截),需届时再决定放宽或等待版本成熟,勿把该报错当作环境问题排查。
## Critical Build Process
The frontend is **NOT** served separately in production. The build process:
@@ -317,7 +320,7 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流
25. **One-time SSH sharing** - Sharing is disabled unless `ENABLE_SSH_SHARING=true`. A link contains only a 256-bit capability, persists only its hash, can be claimed once, and exchanges for a one-minute connection ticket. Creation requires route-scoped verified host fingerprints for the target and every jump hop. Share policy is issued only by SSHShareDO/UserDBDO and must disable Agent, OS detection, host-key mutation, metadata mutation, keyboard-interactive auth, and reconnect while permitting only Terminal and optional SFTP. Record lifecycle, structured SFTP requests/results, and terminal output (not raw keystrokes); stop the session if audit storage fails or reaches 5 MiB/5000 events. Revocation and expiry must close the live SSHSessionDO — including detached sessions held in the 60s re-attach grace window (`detachedSessions`), which are NOT reachable through `this.sessions`. Preserve completed audit metadata if its saved-server record is later deleted.
**Grace-period re-attach(分享会话秒级恢复)**:全新完整重连仍被禁止,但认领设备在断线宽限期内的无缝 reattach 允许,且必须满足:① claim 时绑定设备公钥(`frontend/src/device-identity.ts` 生成的非可导出 ECDSA P-256 密钥,SPKI base64url 经 ShareDO 持久化,Worker 服务端链路下发、客户端不可注入;导出前含持久化回读校验——无痕模式等存储不可靠的环境不绑定公钥,此类分享会话不具备断线恢复资格:断线后即时终结并在终端说明原因);② 恢复请求携带对 `src/share-resume-schema.ts` 规范串的挑战签名,nonce 验签前单次消费防重放;③ 每次成功恢复轮换 resume token(旧 token 降级为“上一代”、仍可容忍一次携旧值重试以覆盖轮换帧丢失,设备签名与 nonce 校验不豁免);④ 到达 `sessionExpiresAt` 或被撤销时立即终结保持中的会话并拒绝恢复;⑤ 拒绝事件写入 `share.resume_denied` 审计。已知理论边界:原设备持有人实时中继签名不可由客户端方案阻止,靠全程审计追责兜底。前端分享连接必须走 resume-only 路径(`connectWithWebSocket(..., { resumeOnly: true })`);重试按指数退避铺满整个断线保持窗口(`SHARE_RESUME_RETRY_WINDOW_MS`,给用户留出切换网络的时间),窗口耗尽或凭据彻底失效时宣告分享结束而非回退完整重连。
**Grace-period re-attach(分享会话秒级恢复)**:全新完整重连仍被禁止,但认领设备在断线宽限期内的无缝 reattach 允许,且必须满足:① claim 时绑定设备公钥(`frontend/src/device-identity.ts` 生成的非可导出 ECDSA P-256 密钥,SPKI base64url 经 ShareDO 持久化,Worker 服务端链路下发、客户端不可注入;导出前含持久化回读校验——无痕模式等存储不可靠的环境不绑定公钥,此类分享会话不具备断线恢复资格:断线后即时终结并在终端说明原因);② 恢复请求携带对 `src/share-resume-schema.ts` 规范串的挑战签名,nonce 验签前单次消费防重放;③ 每次成功恢复轮换 resume token(旧 token 降级为“上一代”、仍可容忍一次携旧值重试以覆盖轮换帧丢失,设备签名与 nonce 校验不豁免);④ 到达 `sessionExpiresAt` 或被撤销时立即终结保持中的会话并拒绝恢复;⑤ 拒绝事件写入 `share.resume_denied` 审计。已知理论边界:原设备持有人实时中继签名不可由客户端方案阻止,靠全程审计追责兜底。前端分享连接必须走 resume-only 路径(`connectWithWebSocket(..., { resumeOnly: true })`);重试按指数退避铺满整个断线保持窗口(`SHARE_RESUME_RETRY_WINDOW_MS`,给用户留出切换网络的时间),窗口耗尽或凭据彻底失效时宣告分享结束而非回退完整重连。审计明细默认长期留存:分享者可在终态后整体清空(写入墓碑事件保留追责线索)或由系统在保留期后自动清理——保留期默认 90 天、可在创建分享时自定义(7–365 天);手动清空会同步取消该排期。清理墓碑事件(`share.audit_purged`/`share.audit_auto_purged`)不进入常规审计列表;SSHShareDO 清理时将留痕(时间与手动/自动方式)同步至所有者 UserDBDO 的 `ssh_shares.audit_purged_at`/`audit_purge_type` 列,管理端在集中的「审计清理记录」折叠区展示全部清理操作,已清理的分享等同删除效果——不再提供查看审计入口。接收者无任何删除能力。**已知边界**:留痕同步(`notifyOwnerAuditPurged`)为尽力而为——部署窗口期旧版 UserDBDO 不认 `/internal/shares/:id/audit-purged` 路由时,清理成功但 `audit_purged_at` 留 NULL,前端会将该分享按“审计仍在”展示(点击为空审计);审计明细已删不可回滚,仅记录日志,前端展示以 `audit_purged_at` 为准。
26. **DNS rebinding SSRF defense** - Address-string checks (`isBlockedHost` / `validateBaseUrl`) alone can be bypassed by domains resolving to private/reserved IPs. `src/worker/dns-check.ts` resolves hostnames via DNS-over-HTTPS (1.1.1.1) and checks every resolved IP against a unified block list covering IPv6 edge cases; it gates both SSH outbound targets (`durable-object.ts`) and AI `base_url` (`agent/ssrf.ts`). When adding address families or reserved ranges, keep the DoH block list and the string-level checks synchronized.
@@ -329,6 +332,8 @@ release: 发布 vX.Y.Z <主题>版本(如 `release: 发布 v1.10.2 工作流
30. **Agent exec 输出有界性(弱网 OOM 防线)** - `AgentExecChannel` 对 exec 通道 stdout/stderr 执行有界捕获:合计 4MB 硬上限(`MAX_EXEC_CAPTURE_BYTES`,超限不再续 SSH window 并由会话层关闭通道击杀远端命令,如无界输出的 `docker logs`),保留头 128KB + 尾 256KB 环形视图并附加截断说明,`onData/onExtendedData` 的布尔返回值控制 window 续期,改动时勿恢复无界累积。守卫不只此一层:`docker_manage(logs)` 强制 `--tail 200` 且拒绝 `-f/--follow`;工具结果序列化进 LLM 前经 64K 字符中间截断;socket 写带 15s deadline(超时关底层 socket 解挂);独立于写路径的被动 idle 看门狗(60s 无入站数据即关闭)与终端输入队列 4MB 上限共同保证弱网下会话必然收敛,勿移除任一防线。
31. **pi-lens 项目策略口径** - `.pi-lens.json` 仅供本机 pi-lens(AI 代码审查插件)读取,不参与构建、部署与 CI 门禁(同 #27 的 Biome 定位),`pi-lens-ignore` 行内注释仅为工具豁免、无运行时行为。其中 `rules.disable` 是已**逐条评估后的误报静音**(以风格类规则为主;`ignore` 仅豁免测试夹具/README/生成文件等路径),而非无差别静音:**XSS 类规则(`no-inner-html`/`ts-xss-dom-sink`)刻意不做项目级禁用**——行内 `pi-lens-ignore` 只豁免逐处核实过的站点(agent-panel 的 Markdown 渲染经 DOMPurify 消毒,其余动态值均 escapeHtml 或来自可信 i18n 词条),未来新增的 innerHTML 站点仍会被规则捕获。新增 innerHTML 时请优先保证转义/消毒并核实后加行内豁免,切勿把这两条加入 `disable` 列表;安全类规则(如 `ast-grep:no-open-redirect`)同理保持克制——扩大禁用清单前先确认告警为误报,优先修复或局部豁免。pi-lens 版本/规则集随设备升级可能产生新告警,处理标准以“是否真实影响运行与门禁”为准。
## Deployment Notes
### 双环境部署
+31
View File
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.12.0] - 2026-08-26
### Added
- 分享审计记录保留与清理能力:分享者在终态后可整体清空全部审计明细(写入墓碑事件保留追责线索,`audit_bytes` 同步重置)或导出完整 JSON 归档(含生命周期与终端输出原文)。
- 审计保留期可自定义(7–365 天,默认 90 天):创建分享时指定;终态进入保留期,到期由 alarm 自动清除明细并写入自动清理墓碑。
- 审计清理留痕集中展示:清理时间与方式(手动/自动)同步至用户库(`audit_purged_at`/`audit_purge_type`)并以折叠面板展示;已清理的分享等同删除效果,不再提供查看审计入口。
- 新增审计清理回归测试(8 用例):墓碑隔离、归属/终态校验、同步失败容错、排期回滚、留痕接缝与迁移幂等。
### Changed
- 审计保留期服务端校验统一为 7–365 天(ShareDO 与 Worker 创建入口白名单一致)。
- 手动清空审计后同步取消已排期的自动清理闹钟,避免到期后的无效唤起。
- 清理墓碑事件(`share.audit_purged`/`share.audit_auto_purged`)从常规审计事件流分离:`ownerView` 单独以 `removals` 返回,前端不再把清洗记录当作普通事件渲染。
- UserDBDO SQL 查询改为类型化封装(`query<T>`/`one<T>`),消除 22 处 `as unknown as` 断言及对应说明注释;COUNT 查询统一走 `one<T>`。
- 分享会话名随机数改由 `crypto.randomUUID()` 生成;OAuth 回调与 base URL 解析增加防御性兜底。
- 测试类型检查纳入门禁:`tests/` 拆分 `tsconfig.worker` / `tsconfig.frontend` 两个项目(workers-types 与 DOM lib 的 `Element` 接口冲突,无法在单一配置共存),并修复暴露的 3 处测试代码类型缺陷。
- 供应链与权限收紧:pnpm 启用 `blockExoticSubdeps`/`minimumReleaseAge`(7 天)/`no-downgrade`;CI 部署权限收敛为 `contents: read`;新增 `.gitleaks.toml` 误报豁免配置。
### Fixed
- 审计保留期闹钟设置失败静默:失败时回滚排期状态并同步内存,杜绝“有排期但无闹钟”的幽灵状态(自动清理永不触发)。
- `updateStatus` 调用点统一 `await`,消除异步化改造后的未处理 rejection 风险。
- 部署窗口期 UserDBDO 不支持审计留痕路由时清理仍可完成(尽力同步,失败仅记录日志)。
- pi-lens 行内抑制失效:误报豁免注释与目标行之间被说明注释隔断,已补为紧邻放置。
### Docs
- AGENTS.md 第 25 条同步清理触发条件、墓碑/removals 域与留痕同步失败边界;新增第 31 条 pi-lens 项目策略口径(XSS 类规则刻意不做项目级禁用,仅行内逐处豁免)。
- AGENTS.md 开发命令补充供应链策略约束说明;`tests/README.md` 说明测试类型检查拆分原因。
## [1.11.0] - 2026-08-25
### Added
-1
View File
@@ -1,5 +1,4 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"enabled": false
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cloudssh-frontend",
"version": "1.11.0",
"version": "1.12.0",
"type": "module",
"scripts": {
"dev": "vite",
+13
View File
@@ -119,6 +119,7 @@ export class AgentPanel {
'w-[560px] max-w-[calc(100vw-200px)] shrink-0 border-l border-[var(--border)] flex flex-col bg-[var(--bg)] overflow-hidden';
this.panelEl.style.display = 'none';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
this.panelEl.innerHTML = `
<div class="agent-panel-header flex items-center justify-between px-4 py-2.5 border-b border-[var(--border)] bg-[var(--bg-elevated)]">
<span class="text-xs font-bold tracking-[0.1em] text-[var(--accent-secondary)]" data-i18n="agent.title">AI Agent 助手</span>
@@ -324,6 +325,7 @@ export class AgentPanel {
if (!context) return;
const source = context.sourceLabel || t('agent.selectionUnknownSource');
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
this.contextEl.innerHTML = `
<div class="agent-context-chip">
<details class="agent-context-details">
@@ -388,6 +390,7 @@ export class AgentPanel {
const container = document.createElement('div');
container.className = 'agent-thinking-process';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
container.innerHTML = `
<button class="tp-accordion" type="button">
<span class="tp-chevron material-symbols-outlined">expand_more</span>
@@ -428,6 +431,7 @@ export class AgentPanel {
if (this.livePreviewCache.length > 2) this.livePreviewCache.shift();
const icon =
'<span class="material-symbols-outlined tp-live-icon" style="font-variation-settings:\'FILL\' 0;">terminal</span>';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
this.thinkingLiveEl.innerHTML = this.livePreviewCache
.map((l) => `<div class="tp-live-item">${icon}<span>${escapeHtml(l)}</span></div>`)
.join('');
@@ -458,6 +462,7 @@ export class AgentPanel {
? '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 0;">terminal</span>'
: '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 1;">smart_toy</span>';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
stepEl.innerHTML = `${icon}<span class="tp-step-label">${escapeHtml(label)}</span>`;
this.thinkingCurrentEl.appendChild(stepEl);
@@ -490,6 +495,7 @@ export class AgentPanel {
step.tool === 'execute_command' || step.tool === 'terminal'
? '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 0;">check_circle</span>'
: '<span class="material-symbols-outlined tp-step-icon" style="font-variation-settings:\'FILL\' 1;">check_circle</span>';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
stepEl.innerHTML = `${icon}<span class="tp-step-label">${escapeHtml(step.label)}</span>`;
this.thinkingStepsEl.appendChild(stepEl);
}
@@ -553,6 +559,7 @@ export class AgentPanel {
const themeColor = 'var(--agent-agent-color)';
const roleIcon = `<span class="material-symbols-outlined text-[14px]" style="color:${themeColor};font-variation-settings:'FILL' 1;">smart_toy</span>`;
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
el.innerHTML = `
<div class="flex gap-2 items-start">
<div class="shrink-0 mt-0.5">${roleIcon}</div>
@@ -587,8 +594,10 @@ export class AgentPanel {
// renderMarkdown() wraps output in its own .agent-md-content div,
// so we extract the inner HTML to avoid nesting.
const tmp = document.createElement('div');
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
tmp.innerHTML = this.renderMarkdown(content || this.streamingText || '');
const inner = tmp.querySelector('.agent-md-content');
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
contentEl.innerHTML = inner ? inner.innerHTML : content || this.streamingText || '';
this.enhanceCodeBlocks(contentEl);
}
@@ -619,6 +628,7 @@ export class AgentPanel {
const el = document.createElement('div');
el.className =
'agent-progress-extend p-2 rounded border border-[var(--accent)] bg-[var(--accent-bg)] text-[11px]';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
el.innerHTML = `
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-[14px]" style="color:var(--accent);font-variation-settings:'FILL' 1;">trending_up</span>
@@ -655,6 +665,7 @@ export class AgentPanel {
el.setAttribute('aria-modal', 'true');
el.setAttribute('aria-labelledby', 'agent-confirm-title');
el.setAttribute('aria-describedby', 'agent-confirm-description');
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
el.innerHTML = `
<div id="agent-confirm-title" class="text-[11px] font-bold text-[var(--error)] mb-1">⚠ ${t('agent.confirmTitle')}</div>
<div class="text-[12px] mb-1 font-code bg-black/20 p-1 rounded">$ ${escapeHtml(command)}</div>
@@ -801,6 +812,7 @@ export class AgentPanel {
// User messages: bubble on right. Agent/others: full width on left.
if (isUser) {
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
el.innerHTML = `
<div class="flex justify-end">
<div class="max-w-[85%] px-3 py-2 rounded-lg" style="background: color-mix(in srgb, ${themeColor} 12%, transparent); border: 1px solid color-mix(in srgb, ${themeColor} 30%, transparent);">
@@ -813,6 +825,7 @@ export class AgentPanel {
</div>
`;
} else {
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
el.innerHTML = `
<div class="flex gap-2 items-start">
<div class="shrink-0 mt-0.5">${roleIcon}</div>
+3 -2
View File
@@ -97,13 +97,14 @@ export class AIConfigPanel {
}
}
}
} catch {}
} catch {
/* AI 配置加载为可选功能,解析失败保持默认即可 */
}
}
private async fetchModels(): Promise<void> {
const baseUrlEl = this.modalEl?.querySelector('#ai-base-url') as HTMLInputElement;
const apiKeyEl = this.modalEl?.querySelector('#ai-api-key') as HTMLInputElement;
const statusEl = this.modalEl?.querySelector('#ai-fetch-status') as HTMLElement;
const modelListEl = this.modalEl?.querySelector('#ai-model-list') as HTMLDataListElement;
const fetchBtn = this.modalEl?.querySelector('#ai-fetch-models-btn') as HTMLButtonElement;
+16 -5
View File
@@ -1,7 +1,10 @@
// 渲染模板中的 innerHTML 站点均带 `pi-lens-ignore: no-inner-html` 内联抑制:
// 动态值均经 escapeHtml 转义或来自可信 i18n 词条,无用户输入直插;
// GitHub Actions 质量门禁不含该规则(AGENTS.md #27)。
import { onLocaleChange, t, translateDocument } from './i18n';
import { loadKnownFingerprint } from './known-hosts';
import { parsePort } from './port';
import { populateRegionSelect, regionLabel } from './regions';
import { populateRegionSelect } from './regions';
import type { TabManager } from './tab-manager';
import { type ColorScheme, getActiveColorScheme, onColorSchemeChange } from './theme';
import { notify } from './ui-feedback';
@@ -19,7 +22,7 @@ async function deriveKey(salt: Uint8Array): Promise<CryptoKey> {
);
}
async function encryptCredentials(data: object): Promise<string> {
async function encryptCredentials(data: Record<string, unknown>): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(salt);
@@ -125,6 +128,7 @@ export class ConnectionForm {
const container = document.getElementById('connection-form-container');
if (!container) return;
// pi-lens-ignore: no-inner-html
container.innerHTML = `
<div class="flex min-h-[320px] flex-col items-center justify-center gap-5 px-4 text-center" id="github-auth-required-panel">
<span class="material-symbols-outlined text-[var(--accent)]" style="font-size: 42px;" aria-hidden="true">lock</span>
@@ -208,6 +212,7 @@ export class ConnectionForm {
private render(): void {
const container = document.getElementById('connection-form-container')!;
// pi-lens-ignore: no-inner-html
container.innerHTML = `
<form class="space-y-6" id="connection-form">
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4">
@@ -382,6 +387,7 @@ export class ConnectionForm {
const authLabel = item.authMethod === 'publickey' ? 'KEY' : 'PWD';
const labelText = `${item.username}@${item.host}:${item.port}`;
// pi-lens-ignore: no-inner-html
itemEl.innerHTML = `
<div class="flex items-center gap-2 overflow-hidden mr-2 select-none flex-1">
<span class="material-symbols-outlined text-muted" style="font-size: 14px;">history</span>
@@ -457,7 +463,9 @@ export class ConnectionForm {
let recent: any[] = [];
try {
recent = raw ? JSON.parse(raw) : [];
} catch {}
} catch {
/* 本地存储损坏时回退为空列表,无需上报 */
}
if (index >= 0 && index < recent.length) {
recent.splice(index, 1);
@@ -588,7 +596,9 @@ export class ConnectionForm {
try {
recent = recentRaw ? JSON.parse(recentRaw) : [];
if (!Array.isArray(recent)) recent = [];
} catch {}
} catch {
/* 本地存储损坏时回退为空列表,无需上报 */
}
const id = `${username}@${host}:${port}`;
const newRecord = {
@@ -643,9 +653,10 @@ export class ConnectionForm {
expectedFingerprint: expectedFingerprint || undefined,
locationHint: regionValue || undefined,
});
} catch (error) {
} catch {
// 连接失败时关闭该标签
tm.closeTab(tab.id);
// pi-lens-ignore: no-inner-html
document.getElementById('status-text')!.innerHTML =
`<span class="w-2 h-2 bg-surface-dot inline-block"></span> ${t('auth.statusOffline')}`;
}
+14
View File
@@ -247,6 +247,18 @@ export const enUS: Record<keyof typeof zhCN, string> = {
'terminal.shareResumeUnsupported':
'This browser cannot pass device identity verification; auto-resume is unavailable and this shared session has ended',
'terminal.status.shareExpiringWarning': 'Note: this shared session ends in less than 1 minute',
'share.auditExport': 'Export',
'share.auditPurge': 'Purge log',
'share.auditPurgeTitle': 'Purge audit log',
'share.auditPurgeMessage':
'This permanently deletes all audit details (including terminal output and operations), keeping only a single tombstone marker. This cannot be undone.',
'share.auditPurged': 'Audit log purged',
'share.auditCleanupLog': 'Audit purge log',
'share.auditPurgeManual': 'Manually purged',
'share.auditPurgeAuto': 'Auto-purged after retention',
'share.auditRemovalHint':
'Purged details cannot be recovered; these entries only record the purge operations.',
'share.auditPurgeFailed': 'Failed to purge audit log',
'terminal.shareResumeEnded':
'Connection lost and could not be resumed. This shared session has ended.',
'terminal.hostKeyChangeTitle': 'SSH host fingerprint changed',
@@ -487,6 +499,8 @@ export const enUS: Record<keyof typeof zhCN, string> = {
'share.linkExpiry': 'Claim window',
'share.sessionDuration': 'Maximum session duration',
'share.minutes': 'minutes',
'share.auditRetention': 'Audit retention',
'share.days': 'days',
'share.createWarning':
'A share link is a temporary access credential. The recipient receives a full terminal and SFTP; send it only through a trusted channel. Every host fingerprint in the route must already be verified.',
'share.create': 'Create share',
+13
View File
@@ -202,6 +202,17 @@ export const zhCN = {
'terminal.shareResumeUnsupported':
'当前浏览器环境无法通过设备身份校验,不支持断线自动恢复,本次分享会话已结束',
'terminal.status.shareExpiringWarning': '注意:分享会话将在 1 分钟内结束',
'share.auditExport': '导出',
'share.auditPurge': '清空审计',
'share.auditPurgeTitle': '清空审计记录',
'share.auditPurgeMessage':
'将永久删除全部审计明细(含终端输出与操作记录),仅保留一条清除标记。此操作不可恢复。',
'share.auditPurged': '审计记录已清空',
'share.auditCleanupLog': '审计清理记录',
'share.auditPurgeManual': '手动清空',
'share.auditPurgeAuto': '到期自动清理',
'share.auditRemovalHint': '已清除的明细不可恢复,此处仅保留清理操作的记录。',
'share.auditPurgeFailed': '清空审计失败',
'terminal.shareResumeEnded': '连接已断开且无法恢复,本次分享会话已结束。',
'terminal.hostKeyChangeTitle': 'SSH 主机指纹已变更',
'terminal.hostKeySaveTitle': '无法记录 SSH 主机指纹',
@@ -446,6 +457,8 @@ export const zhCN = {
'share.linkExpiry': '链接领取有效期',
'share.sessionDuration': '会话最长时间',
'share.minutes': '分钟',
'share.auditRetention': '审计保留期',
'share.days': '天',
'share.createWarning':
'分享链接等同于临时访问凭证。接收者可使用完整终端和 SFTP,请仅通过可信渠道发送。创建前必须已通过普通连接验证全部主机指纹。',
'share.create': '创建分享',
+1
View File
@@ -45,6 +45,7 @@ export function populateRegionSelect(
el: HTMLSelectElement,
selected: string | null | undefined
): void {
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
el.innerHTML = REGION_OPTIONS.map(
(o) =>
`<option value="${o.value}" ${o.value === (selected || '') ? 'selected' : ''}>${t(o.labelKey)}</option>`
+9 -1
View File
@@ -332,6 +332,7 @@ export class ServerList {
tagFilterWrapper?.classList.toggle('hidden', allTags.length === 0);
if (tagFilter) {
if (this.selectedTag && !allTags.includes(this.selectedTag)) this.selectedTag = '';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
tagFilter.innerHTML = [
`<option value="">${t('server.allTags')}</option>`,
...allTags.map(
@@ -358,6 +359,7 @@ export class ServerList {
this.currentPage = page.currentPage;
const visibleServers = page.items;
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
grid.innerHTML = visibleServers.map((server) => this.renderServerCard(server)).join('');
// 绑定卡片事件
@@ -526,6 +528,7 @@ export class ServerList {
const connectBtn = document.getElementById(`connect-${serverId}`);
if (connectBtn) {
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
connectBtn.innerHTML = `
<span class="material-symbols-outlined animate-spin" style="font-size: 14px;">progress_activity</span>
${t('server.connecting')}
@@ -563,6 +566,7 @@ export class ServerList {
});
} finally {
if (connectBtn) {
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
connectBtn.innerHTML = `
<span class="material-symbols-outlined" style="font-size: 14px;">power_settings_new</span>
${t('common.connect')}
@@ -623,6 +627,7 @@ export class ServerList {
if (!modal || !title || !submitBtn) return;
title.textContent = mode === 'add' ? t('server.add') : t('server.edit');
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
submitBtn.innerHTML = `
<span class="material-symbols-outlined" style="font-size: 18px;">save</span>
${mode === 'add' ? t('server.save') : t('server.update')}
@@ -844,6 +849,7 @@ export class ServerList {
const submitBtn = document.getElementById('server-submit-btn') as HTMLButtonElement;
submitBtn.disabled = true;
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
submitBtn.innerHTML = `
<span class="material-symbols-outlined animate-spin" style="font-size: 18px;">progress_activity</span>
${t('server.saving')}
@@ -896,7 +902,7 @@ export class ServerList {
// DEBUG_MODE 时,响应中包含 _debug 字段:显示完整调试日志
if (debugLines) {
console.log('[locationHint 调试信息]');
debugLines.forEach((msg) => console.log(msg));
for (const msg of debugLines) console.log(msg);
this.showDebugNotification(debugLines);
}
@@ -931,6 +937,7 @@ export class ServerList {
});
} finally {
submitBtn.disabled = false;
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
submitBtn.innerHTML = `
<span class="material-symbols-outlined" style="font-size: 18px;">save</span>
${this.editingServerId ? t('server.update') : t('server.save')}
@@ -961,6 +968,7 @@ export class ServerList {
const closeBtn = document.createElement('button');
closeBtn.className =
'absolute top-2 right-2 text-muted hover:text-[var(--accent)] cursor-pointer';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
closeBtn.innerHTML =
'<span class="material-symbols-outlined" style="font-size: 16px;">close</span>';
closeBtn.onclick = () => notification.remove();
+9 -4
View File
@@ -197,6 +197,7 @@ export class SFTPPanel {
panel.style.width = 'min(420px, 100vw)';
panel.style.transform = 'translateX(100%)';
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
panel.innerHTML = `
<div class="flex flex-col w-full h-full bg-surface border-l border-outline-variant text-on-surface">
<!-- Header -->
@@ -577,7 +578,9 @@ export class SFTPPanel {
this.closedByPanel.add(ws);
try {
ws.close(code, reason);
} catch {}
} catch {
/* 连接可能已关闭,close 抛出可忽略(幂等操作) */
}
}
}
@@ -767,6 +770,7 @@ export class SFTPPanel {
});
this.renderedEntries = sorted;
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
entriesContainer.innerHTML = sorted
.map(
(entry, idx) => `
@@ -799,7 +803,7 @@ export class SFTPPanel {
});
});
el.addEventListener('dblclick', (e) => {
el.addEventListener('dblclick', () => {
const target = el as HTMLElement;
const idx = parseInt(target.dataset['idx']!);
const entry = sorted[idx];
@@ -941,6 +945,7 @@ export class SFTPPanel {
className: 'text-error',
});
// pi-lens-ignore: no-inner-html, ts-xss-dom-sink
menu.innerHTML = items
.map(
(item) => `
@@ -1346,7 +1351,7 @@ export class SFTPPanel {
this.uploadWaiter.resolveProgress(loaded);
}
private onUploadComplete(path: string): void {
private onUploadComplete(_path: string): void {
this.uploadWaiter.resolveComplete();
this.uploadActive = false;
this.uploadCancelRequested = false;
@@ -1567,7 +1572,7 @@ export class SFTPPanel {
(this.container.querySelector('#sftp-status-text') as HTMLElement).textContent = text;
}
private updateItemCount(count: number): void {
private updateItemCount(_count: number): void {
const dirs = this.entries.filter((e) => e.isDir).length;
const files = this.entries.filter((e) => !e.isDir).length;
(this.container.querySelector('#sftp-item-count') as HTMLElement).textContent = t(
+159 -33
View File
@@ -16,6 +16,9 @@ interface ShareSummary {
activeAt: number | null;
closedAt: number | null;
createdAt: number;
/** 审计明细被清理的时间与方式;NULL 表示审计仍在,可查看 */
auditPurgedAt?: number | null;
auditPurgeType?: 'manual' | 'auto' | null;
}
interface AuditEvent {
@@ -52,7 +55,10 @@ function formatShareStatus(status: string | undefined): string {
function stripTerminalControls(value: string): string {
return value
// 本函数的目的即去除 ANSI 控制序列(OSC \x1b]... / CSI \x1b[...),正则中的控制字符为设计意图。
// pi-lens-ignore: lint/suspicious/noControlCharactersInRegex
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
// pi-lens-ignore: lint/suspicious/noControlCharactersInRegex
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '');
}
@@ -102,6 +108,13 @@ export class ShareManager {
<option value="60" selected>60 ${t('share.minutes')}</option><option value="120">120 ${t('share.minutes')}</option>
</select>
</label>
<label class="text-xs text-muted">${t('share.auditRetention')}
<select id="share-audit-retention" class="terminal-input w-full mt-1">
<option value="7">7 ${t('share.days')}</option><option value="30">30 ${t('share.days')}</option>
<option value="90" selected>90 ${t('share.days')}</option><option value="180">180 ${t('share.days')}</option>
<option value="365">365 ${t('share.days')}</option>
</select>
</label>
</div>
<p class="text-[11px] text-muted mb-4">${t('share.createWarning')}</p>
<button id="share-create-btn" type="button" class="cyber-button text-primary px-4 py-2 text-xs font-bold flex items-center gap-2">
@@ -141,12 +154,15 @@ export class ShareManager {
const maxSessionMinutes = Number(
(document.getElementById('share-session-duration') as HTMLSelectElement).value
);
const auditRetentionDays = Number(
(document.getElementById('share-audit-retention') as HTMLSelectElement).value
);
if (button) button.disabled = true;
try {
const response = await fetch(`/api/servers/${this.serverId}/shares`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInMinutes, maxSessionMinutes }),
body: JSON.stringify({ expiresInMinutes, maxSessionMinutes, auditRetentionDays }),
});
const payload = (await response.json().catch(() => ({}))) as {
url?: string;
@@ -199,11 +215,29 @@ export class ShareManager {
container.innerHTML = `<p class="text-xs text-muted">${t('share.none')}</p>`;
return;
}
const purged = shares.filter((s) => s.auditPurgedAt);
// 已清理审计的分享等同删除效果:不再渲染卡片,仅在下方清理留痕区留一条记录
const visible = shares.filter((s) => !s.auditPurgedAt);
// 集中式清理留痕区:所有已清理的分享都汇总在这里(默认收起)
const cleanupSection =
purged.length > 0
? `<details class="mb-3 border border-[var(--border)] p-2">
<summary class="text-[10px] text-muted cursor-pointer select-none">
${t('share.auditCleanupLog')}(${purged.length})
</summary>
<div class="space-y-1 mt-2">
${purged.map((share) => `<div class="text-[10px] text-muted"><span class="text-dim">${escapeHtml(formatTime(share.auditPurgedAt ?? null))}</span> ${escapeHtml(t(`share.status.${share.status}` as never))} · ${share.auditPurgeType === 'auto' ? t('share.auditPurgeAuto') : t('share.auditPurgeManual')}</div>`).join('')}
</div>
<p class="text-[10px] text-dim mt-1">${t('share.auditRemovalHint')}</p>
</details>`
: '';
// pi-lens-ignore: no-inner-html
container.innerHTML = shares
.map((share) => {
const revocable = ['unused', 'claimed', 'active'].includes(share.status);
return `<div class="border border-[var(--border)] p-3" data-share-id="${escapeHtml(share.id)}">
container.innerHTML =
cleanupSection +
visible
.map((share) => {
const revocable = ['unused', 'claimed', 'active'].includes(share.status);
return `<div class="border border-[var(--border)] p-3" data-share-id="${escapeHtml(share.id)}">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="text-xs text-on-surface">${t(`share.status.${share.status}` as never)}</div>
@@ -216,8 +250,8 @@ export class ShareManager {
</div>
</div>
</div>`;
})
.join('');
})
.join('');
for (const button of container.querySelectorAll<HTMLElement>('[data-share-audit]')) {
const shareId = button.dataset.shareAudit;
if (!shareId) continue;
@@ -262,30 +296,7 @@ export class ShareManager {
// pi-lens-ignore: no-inner-html
view.innerHTML = `<p class="text-xs text-muted">${t('common.loading')}</p>`;
try {
const events: AuditEvent[] = [];
let after = 0;
let hasMore = true;
let share: { status: string; auditBytes: number } | null = null;
while (hasMore && events.length < 5000) {
const response = await fetch(
`/api/shares/${encodeURIComponent(shareId)}/audit?after=${after}&limit=500`
);
const payload = (await response.json().catch(() => ({}))) as {
share?: { status: string; auditBytes: number };
events?: AuditEvent[];
hasMore?: boolean;
nextAfter?: number;
error?: string;
};
if (!response.ok || !payload.share || !payload.events)
throw new Error(payload.error || t('share.auditFailed'));
share = payload.share;
events.push(...payload.events);
hasMore = payload.hasMore === true;
const next = payload.nextAfter ?? after;
if (next <= after) break;
after = next;
}
const { share, events } = await this.fetchAllAudit(shareId);
const output = stripTerminalControls(
events
.filter((event) => event.eventType === 'terminal.output')
@@ -293,24 +304,134 @@ export class ShareManager {
.join('')
);
const structured = events.filter((event) => event.eventType !== 'terminal.output');
const canPurge =
share?.status === 'closed' || share?.status === 'revoked' || share?.status === 'expired';
// pi-lens-ignore: no-inner-html
view.innerHTML = `
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-bold text-primary">${t('share.auditTitle')}</h3>
<span class="text-[10px] text-muted">${formatShareStatus(share?.status)} · ${Math.ceil((share?.auditBytes || 0) / 1024)} KiB</span>
<div class="flex items-center gap-2">
<span class="text-[10px] text-muted">${formatShareStatus(share?.status)} · ${Math.ceil((share?.auditBytes || 0) / 1024)} KiB</span>
<button type="button" data-audit-export class="cyber-button px-2 py-1 text-[10px]">${t('share.auditExport')}</button>
${canPurge ? `<button type="button" data-audit-purge class="cyber-button px-2 py-1 text-[10px] text-error">${t('share.auditPurge')}</button>` : ''}
</div>
</div>
<div class="space-y-1 mb-4 max-h-48 overflow-y-auto custom-scrollbar">
${structured.length ? structured.map((event) => `<div class="text-[10px] text-muted"><span class="text-dim">${escapeHtml(formatTime(event.occurredAt))}</span> ${escapeHtml(this.describeEvent(event))}</div>`).join('') : `<p class="text-xs text-muted">${t('share.auditNone')}</p>`}
${structured.map((event) => `<div class="text-[10px] text-muted"><span class="text-dim">${escapeHtml(formatTime(event.occurredAt))}</span> ${escapeHtml(this.describeEvent(event))}</div>`).join('') || `<p class="text-xs text-muted">${t('share.auditNone')}</p>`}
</div>
<h4 class="text-[11px] font-bold text-primary mb-2">${t('share.terminalRecord')}</h4>
<pre class="bg-[var(--bg)] border border-[var(--border)] p-3 text-[11px] text-on-surface whitespace-pre-wrap break-words max-h-72 overflow-auto custom-scrollbar">${escapeHtml(output || t('share.noTerminalOutput'))}</pre>
`;
for (const button of view.querySelectorAll<HTMLElement>('[data-audit-export]')) {
button.addEventListener('click', () => void this.exportAudit(shareId));
}
for (const button of view.querySelectorAll<HTMLElement>('[data-audit-purge]')) {
button.addEventListener('click', () => void this.purgeAudit(shareId));
}
} catch (error) {
// pi-lens-ignore: no-inner-html
view.innerHTML = `<p class="text-xs text-error">${escapeHtml(error instanceof Error ? error.message : t('share.auditFailed'))}</p>`;
}
}
/** 拉取指定分享的全部审计事件、清理记录与元信息(分页聚合)。 */
private async fetchAllAudit(shareId: string): Promise<{
share: { status: string; auditBytes: number } | null;
events: AuditEvent[];
removals: Array<{ occurredAt: number; eventType: string }>;
}> {
const events: AuditEvent[] = [];
let removals: Array<{ occurredAt: number; eventType: string }> = [];
let share: { status: string; auditBytes: number } | null = null;
let after = 0;
let hasMore = true;
while (hasMore && events.length < 5000) {
const response = await fetch(
`/api/shares/${encodeURIComponent(shareId)}/audit?after=${after}&limit=500`
);
const payload = (await response.json().catch(() => ({}))) as {
share?: { status: string; auditBytes: number };
events?: AuditEvent[];
removals?: Array<{ occurredAt: number; eventType: string }>;
hasMore?: boolean;
nextAfter?: number;
error?: string;
};
if (!response.ok || !payload.share || !payload.events)
throw new Error(localizedApiError(payload, 'share.auditFailed'));
share = { status: payload.share.status, auditBytes: payload.share.auditBytes };
events.push(...payload.events);
// 清理记录每页都全量返回,直接覆盖避免重复累积
if (payload.removals) removals = payload.removals;
hasMore = payload.hasMore === true;
const next = payload.nextAfter ?? after;
if (next <= after) break;
after = next;
}
return { share, events, removals };
}
/** 导出全部审计事件为 JSON 归档(含生命周期与终端输出原文)。 */
private async exportAudit(shareId: string): Promise<void> {
try {
const { share, events, removals } = await this.fetchAllAudit(shareId);
const payload = {
app: 'CloudSSH',
kind: 'share-audit-export',
exportedAt: new Date().toISOString(),
shareId,
status: share?.status ?? null,
auditBytes: share?.auditBytes ?? 0,
events,
auditRemovals: removals,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `cloudssh-share-audit-${shareId}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch {
notify(t('share.auditFailed'), { variant: 'danger' });
}
}
/** 清空终态会话的全部审计明细(服务端写入墓碑,不可恢复)。 */
private async purgeAudit(shareId: string): Promise<void> {
const confirmed = await confirmAction({
title: t('share.auditPurgeTitle'),
message: t('share.auditPurgeMessage'),
confirmText: t('share.auditPurge'),
cancelText: t('common.cancel'),
variant: 'danger',
});
if (!confirmed) return;
try {
const response = await fetch(`/api/shares/${encodeURIComponent(shareId)}/audit`, {
method: 'DELETE',
});
const payload = (await response.json().catch(() => ({}))) as { error?: string };
if (!response.ok) throw new Error(localizedApiError(payload, 'share.auditPurgeFailed'));
notify(t('share.auditPurged'), { variant: 'success' });
// 清理等同删除效果:立即收起审计视图并刷新列表,隐藏该分享的查看入口
const view = document.getElementById('share-audit-view');
if (view) {
view.classList.add('hidden');
view.innerHTML = '';
}
await this.loadShares();
} catch (error) {
notify(error instanceof Error ? error.message : t('share.auditPurgeFailed'), {
variant: 'danger',
});
}
}
private describeEvent(event: AuditEvent): string {
const details = event.details;
if (event.eventType === 'sftp.request' || event.eventType === 'sftp.result') {
@@ -323,5 +444,10 @@ export class ShareManager {
return `SFTP ${operation}${path ? ` ${path}` : ''} · ${result}`;
}
return t(`share.event.${event.eventType}` as never);
// 注意:share.event.share.audit_purged / audit_auto_purged 两条墓碑词条已移除——
// 新版后端 ownerView 已把两类墓碑事件从事件列表过滤(removals 单独返回),
// 正常路径不会命中;且前后端同源内联原子部署(build-html → 同一 Worker),
// 不存在“新前端调用旧后端”的窗口。如未来启用渐近发布等新旧 Worker 共存
// 方案,再按需补回词条。
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "cloudssh",
"version": "1.11.0",
"version": "1.12.0",
"packageManager": "pnpm@11.8.0",
"description": "纯 Cloudflare 架构 Web SSH 工具",
"main": "src/worker/index.ts",
@@ -11,7 +11,7 @@
"sync:theme-editor": "node scripts/sync-theme-editor.js",
"build:frontend": "node scripts/build-html.js",
"typecheck": "pnpm run typecheck:worker && pnpm run typecheck:frontend",
"typecheck:worker": "tsc --noEmit",
"typecheck:worker": "tsc --noEmit && tsc -p tests/tsconfig.worker.json --noEmit && tsc -p tests/tsconfig.frontend.json --noEmit",
"typecheck:frontend": "pnpm --dir frontend exec tsc --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
@@ -36,6 +36,7 @@
"@axe-core/playwright": "4.12.1",
"@cloudflare/workers-types": "^4.20260630.1",
"@playwright/test": "1.62.0",
"@types/node": "^26.2.0",
"@vitest/coverage-v8": "4.1.10",
"typescript": "^5.7.0",
"vitest": "4.1.10",
+26 -9
View File
@@ -17,6 +17,9 @@ importers:
'@playwright/test':
specifier: 1.62.0
version: 1.62.0
'@types/node':
specifier: ^26.2.0
version: 26.2.0
'@vitest/coverage-v8':
specifier: 4.1.10
version: 4.1.10(vitest@4.1.10)
@@ -25,7 +28,7 @@ importers:
version: 5.7.2
vitest:
specifier: 4.1.10
version: 4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7))
version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))
wrangler:
specifier: ^4.106.0
version: 4.106.0(@cloudflare/workers-types@4.20260630.1)
@@ -77,7 +80,7 @@ importers:
version: 5.7.2
vite:
specifier: ^6.0.0
version: 6.4.3(jiti@1.21.7)
version: 6.4.3(@types/node@26.2.0)(jiti@1.21.7)
packages:
@@ -832,6 +835,9 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
@@ -1386,6 +1392,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
@@ -1974,6 +1983,10 @@ snapshots:
'@types/estree@1.0.9': {}
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
'@types/trusted-types@2.0.7':
optional: true
@@ -1989,7 +2002,7 @@ snapshots:
obug: 2.1.3
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7))
vitest: 4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))
'@vitest/expect@4.1.10':
dependencies:
@@ -2000,13 +2013,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.10(vite@6.4.3(jiti@1.21.7))':
'@vitest/mocker@4.1.10(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.3(jiti@1.21.7)
vite: 6.4.3(@types/node@26.2.0)(jiti@1.21.7)
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -2579,6 +2592,8 @@ snapshots:
typescript@5.7.2: {}
undici-types@8.3.0: {}
undici@7.28.0: {}
unenv@2.0.0-rc.24:
@@ -2593,7 +2608,7 @@ snapshots:
util-deprecate@1.0.2: {}
vite@6.4.3(jiti@1.21.7):
vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7):
dependencies:
esbuild: 0.25.0
fdir: 6.5.0(picomatch@4.0.4)
@@ -2602,13 +2617,14 @@ snapshots:
rollup: 4.62.2
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.2.0
fsevents: 2.3.3
jiti: 1.21.7
vitest@4.1.10(@vitest/coverage-v8@4.1.10)(vite@6.4.3(jiti@1.21.7)):
vitest@4.1.10(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@6.4.3(jiti@1.21.7))
'@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@26.2.0)(jiti@1.21.7))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -2625,9 +2641,10 @@ snapshots:
tinyexec: 1.2.4
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 6.4.3(jiti@1.21.7)
vite: 6.4.3(@types/node@26.2.0)(jiti@1.21.7)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0
'@vitest/coverage-v8': 4.1.10(vitest@4.1.10)
transitivePeerDependencies:
- msw
+3
View File
@@ -5,3 +5,6 @@ allowBuilds:
esbuild: true
sharp: true
workerd: true
blockExoticSubdeps: true
minimumReleaseAge: 10080
trustPolicy: no-downgrade
+1
View File
@@ -73,6 +73,7 @@ function readObjectLiteral(source, marker) {
const objectEnd = findMatchingBrace(source, objectStart);
const literal = source.slice(objectStart, objectEnd + 1);
// 仓库源码属于受信任输入;这里只执行抽取出的纯数据对象,供静态 Pages 使用。
// pi-lens-ignore: no-global-eval-js
return Function(`"use strict"; return (${literal});`)();
}
+4
View File
@@ -62,6 +62,8 @@ export class SSHAESGCMCipher {
if (!this.key) throw new Error('Cipher not initialized');
const algorithm = this.getAlgorithm(aad);
// SAFETY: getAlgorithm 返回的算法对象仅含 AesGcmParams 支持的分段;
// WebCrypto 类型签名过窄,运行时与 TS 均接受该形状
const encrypted = new Uint8Array(
await crypto.subtle.encrypt(
algorithm as unknown as SubtleCryptoEncryptAlgorithm,
@@ -84,6 +86,8 @@ export class SSHAESGCMCipher {
const algorithm = this.getAlgorithm(aad);
try {
// SAFETY: getAlgorithm 返回的算法对象仅含 AesGcmParams 支持的分段;
// WebCrypto 类型签名过窄,运行时与 TS 均接受该形状
const decrypted = new Uint8Array(
await crypto.subtle.decrypt(
algorithm as unknown as SubtleCryptoEncryptAlgorithm,
+2
View File
@@ -23,6 +23,8 @@ type X25519SubtleCrypto = {
};
function x25519Subtle(): X25519SubtleCrypto {
// SAFETY: Workers 运行时基于 WebCrypto 扩展曲线算法;类型缺官方声明,
// 接口按运行时实际形状定义,此转换仅为补全 TS 签名
return crypto.subtle as unknown as X25519SubtleCrypto;
}
+2
View File
@@ -23,6 +23,8 @@ type ECDHSubtleCrypto = {
};
function ecdhSubtle(): ECDHSubtleCrypto {
// SAFETY: Workers 运行时基于 WebCrypto 扩展曲线算法;类型缺官方声明,
// 接口按运行时实际形状定义,此转换仅为补全 TS 签名
return crypto.subtle as unknown as ECDHSubtleCrypto;
}
+1 -1
View File
@@ -77,6 +77,6 @@ export function extractRawECDHPoint(blob: Uint8Array): Uint8Array {
return blob.subarray(offset, offset + pointLen);
}
export function encodePrefixedString(str: string): Uint8Array {
export function encodePrefixedString(str: string | Uint8Array): Uint8Array {
return encodeString(str);
}
+15 -4
View File
@@ -18,8 +18,13 @@ function parseCookies(request: Request): Record<string, string> {
function getBaseUrl(env: Env, request: Request): string {
if (env.BASE_URL) return env.BASE_URL.replace(/\/$/, '');
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
// request.url 由 Workers 运行时保证为合法绝对 URL;防御性兜底避免抛错。
try {
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
} catch {
return '';
}
}
// ==================== 获取 UserDBDO stub ====================
@@ -41,7 +46,7 @@ interface GitHubAccessPolicy {
* - 已配置但为空:拒绝所有 GitHub 用户。
* - 含非法值:配置无效并 fail closed,避免误开放实例。
*/
export function getGitHubAccessPolicy(env: Env): GitHubAccessPolicy {
function getGitHubAccessPolicy(env: Env): GitHubAccessPolicy {
const raw = env.GITHUB_ALLOWED_USER_IDS;
if (raw === undefined) {
return { restricted: false, valid: true, allowedIds: new Set() };
@@ -154,7 +159,13 @@ export async function handleGitHubCallback(request: Request, env: Env): Promise<
return Response.json({ error: 'GitHub OAuth not configured' }, { status: 501 });
}
const url = new URL(request.url);
// request.url 由 Workers 运行时保证为合法绝对 URL;防御性兜底避免抛出。
let url: URL;
try {
url = new URL(request.url);
} catch {
return new Response('Invalid callback URL', { status: 400 });
}
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const baseUrl = getBaseUrl(env, request);
+3 -1
View File
@@ -75,7 +75,9 @@ export class DirectTcpipStream {
this.pendingReads = [];
try {
this.controller?.close();
} catch {}
} catch {
/* 流可能已终断,关闭重复调用可忽略 */
}
this.controller = null;
void this.closeChannel().catch(() => {});
}
+3 -2
View File
@@ -153,7 +153,8 @@ export class SSHSessionDO {
}
let prefilledConfig: SSHConnectionConfig | null = null;
const sessionName = url.searchParams.get('session') || `session:${Date.now()}:${Math.random()}`;
const sessionName =
url.searchParams.get('session') || `session:${Date.now()}:${crypto.randomUUID()}`;
if (request.method === 'POST') {
try {
@@ -733,7 +734,7 @@ export class SSHSessionDO {
this.sessions.set(ws, session);
// 生成 256 位加密随机 Token 并下发会话凭据
const tokenSessionId = sessionName || `session:${Date.now()}:${Math.random()}`;
const tokenSessionId = sessionName || `session:${Date.now()}:${crypto.randomUUID()}`;
const resumeToken =
crypto.randomUUID().replace(/-/g, '') + crypto.randomUUID().replace(/-/g, '');
this.sessionToSessionId.set(session, tokenSessionId);
+23 -3
View File
@@ -413,15 +413,25 @@ async function handleServersRoute(request: Request, url: URL, env: Env): Promise
);
}
if (request.method === 'POST') {
const body = await request.json<{ expiresInMinutes?: number; maxSessionMinutes?: number }>();
const body = await request.json<{
expiresInMinutes?: number;
maxSessionMinutes?: number;
auditRetentionDays?: number;
}>();
const expiresInMinutes = Number(body.expiresInMinutes);
const maxSessionMinutes = Number(body.maxSessionMinutes);
// 审计保留天数:缺省 90;白名单与前端选项一致
const auditRetentionDays =
body.auditRetentionDays === undefined ? 90 : Number(body.auditRetentionDays);
if (![5, 15, 30, 60].includes(expiresInMinutes)) {
return Response.json({ error: 'Invalid share expiry' }, { status: 400 });
}
if (![15, 30, 60, 120].includes(maxSessionMinutes)) {
return Response.json({ error: 'Invalid maximum session duration' }, { status: 400 });
}
if (![7, 30, 90, 180, 365].includes(auditRetentionDays)) {
return Response.json({ error: 'Invalid audit retention' }, { status: 400 });
}
const token = createShareToken();
const shareRef = await hashShareToken(token);
@@ -461,6 +471,7 @@ async function handleServersRoute(request: Request, url: URL, env: Env): Promise
serverName: metadata.serverName,
expiresAt,
maxSessionSeconds: maxSessionMinutes * 60,
auditRetentionDays,
}),
})
);
@@ -864,7 +875,7 @@ async function handleSSHConnection(request: Request, env: Env): Promise<Response
return Response.json({ error: 'GitHub authentication required' }, { status: 401 });
}
const sessionName = `session:${Date.now()}:${Math.random()}`;
const sessionName = `session:${Date.now()}:${crypto.randomUUID()}`;
const doId = env.SSH_SESSION.idFromName(sessionName);
// 匿名路径不做自动推断(Worker 在 upgrade 时拿不到 host);
// 仅尊重用户通过前端下拉手动传入的 ?region= 覆盖值
@@ -1001,6 +1012,15 @@ async function handleShareOwnerRoute(request: Request, url: URL, env: Env): Prom
)
);
}
if (url.pathname.endsWith('/audit') && request.method === 'DELETE') {
return shareStub.fetch(
new Request('http://internal/internal/audit/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ownerUserId: user.id }),
})
);
}
if (!url.pathname.endsWith('/audit') && request.method === 'DELETE') {
return shareStub.fetch(new Request('http://internal/internal/revoke', { method: 'POST' }));
}
@@ -1126,7 +1146,7 @@ async function handleTokenSSHConnection(
);
}
const sessionName = `session:${Date.now()}:${Math.random()}`;
const sessionName = `session:${Date.now()}:${crypto.randomUUID()}`;
const doId = env.SSH_SESSION.idFromName(sessionName);
// Token 路径:locationHint 由 user-db.handleConnectServer 按最外层直连节点计算并写入 config
// (优先级:入口服务器手动 region → 入口 DB 持久化 inferred_hint → undefined)
+157 -10
View File
@@ -2,6 +2,12 @@ import type { Env, SSHConnectionConfig } from '../types';
const MAX_AUDIT_BYTES = 5 * 1024 * 1024;
const MAX_AUDIT_EVENTS = 5000;
/** 审计保留期默认值(天):创建分享时可按链接自定义(7–365)。 */
const DEFAULT_AUDIT_RETENTION_DAYS = 90;
const MS_PER_DAY = 86_400_000;
const TERMINAL_SHARE_STATUSES: ReadonlySet<ShareStatus> = new Set(['closed', 'revoked', 'expired']);
const CONNECT_TICKET_TTL_MS = 60_000;
type ShareStatus = 'unused' | 'claimed' | 'active' | 'closed' | 'revoked' | 'expired';
@@ -25,6 +31,8 @@ interface ShareStateRow {
ticket_expires_at: number | null;
audit_bytes: number;
device_pub_key: string | null;
audit_purge_due: number | null;
audit_retention_days: number | null;
}
interface ShareInitBody {
@@ -36,6 +44,8 @@ interface ShareInitBody {
serverName: string;
expiresAt: number;
maxSessionSeconds: number;
/** 审计明细保留天数(7–365);缺省时服务端取默认 90 天。 */
auditRetentionDays?: number;
}
function jsonError(error: string, status: number): Response {
@@ -117,6 +127,18 @@ export class SSHShareDO {
} catch {
/* column already exists */
}
// 迁移:审计保留期到期时间(终态后自动清理调度用)。
try {
this.db.exec('ALTER TABLE share_state ADD COLUMN audit_purge_due INTEGER');
} catch {
/* column already exists */
}
// 迁移:审计保留天数(创建时可自定义;NULL 表示使用默认 90 天)。
try {
this.db.exec('ALTER TABLE share_state ADD COLUMN audit_retention_days INTEGER');
} catch {
/* column already exists */
}
}
async fetch(request: Request): Promise<Response> {
@@ -148,6 +170,9 @@ export class SSHShareDO {
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 500));
return this.ownerView(ownerUserId, after, limit);
}
if (url.pathname === '/internal/audit/purge' && request.method === 'POST') {
return this.purgeAudit(await request.json<{ ownerUserId?: number }>());
}
return new Response('Not Found', { status: 404 });
} catch (error) {
console.error('SSHShareDO error:', error instanceof Error ? error.message : String(error));
@@ -159,8 +184,18 @@ export class SSHShareDO {
const share = this.getShare();
if (!share) return;
const now = Date.now();
// 审计保留期:终态满 90 天自动清除明细并写入自动清理墓碑
if (
TERMINAL_SHARE_STATUSES.has(share.status) &&
share.audit_purge_due !== null &&
now >= share.audit_purge_due
) {
await this.purgeAuditContent(share, 'share.audit_auto_purged');
this.db.exec('UPDATE share_state SET audit_purge_due = NULL');
return;
}
if (share.status === 'unused' && now >= share.expires_at) {
this.updateStatus(share, 'expired', now);
await this.updateStatus(share, 'expired', now);
await this.syncOwnerMetadata(share, 'expired', { closedAt: now });
return;
}
@@ -193,11 +228,24 @@ export class SSHShareDO {
) {
return jsonError('Invalid maximum session duration', 400);
}
// 审计保留天数:可选;未提供时存 NULL(运行时取默认 90 天)。
let auditRetentionDays: number | null = null;
if (body.auditRetentionDays !== undefined) {
if (
!Number.isInteger(body.auditRetentionDays) ||
body.auditRetentionDays < 7 ||
body.auditRetentionDays > 365
) {
return jsonError('Invalid audit retention', 400);
}
auditRetentionDays = body.auditRetentionDays;
}
this.db.exec(
`INSERT INTO share_state (
singleton, share_id, token_hash, owner_user_id, owner_github_id,
server_id, server_name, expires_at, max_session_seconds, status
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
server_id, server_name, expires_at, max_session_seconds,
audit_retention_days, status
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unused')`,
body.shareId,
body.tokenHash,
body.ownerUserId,
@@ -205,7 +253,8 @@ export class SSHShareDO {
body.serverId,
body.serverName,
body.expiresAt,
body.maxSessionSeconds
body.maxSessionSeconds,
auditRetentionDays
);
await this.state.storage.setAlarm(body.expiresAt);
return Response.json({ success: true });
@@ -225,7 +274,7 @@ export class SSHShareDO {
if (share.status !== 'unused')
return jsonError('This share link has already been used or revoked', 409);
if (now >= share.expires_at) {
this.updateStatus(share, 'expired', now);
await this.updateStatus(share, 'expired', now);
await this.syncOwnerMetadata(share, 'expired', { closedAt: now });
return jsonError('This share link has expired', 410);
}
@@ -303,7 +352,7 @@ export class SSHShareDO {
if (!configResponse.ok) {
const error = await configResponse.text();
await this.appendAudit('session.connection_failed', { status: configResponse.status }, now);
this.updateStatus(active, 'closed', now);
await this.updateStatus(active, 'closed', now);
await this.syncOwnerMetadata(active, 'closed', { closedAt: now });
return new Response(error, {
status: configResponse.status,
@@ -357,7 +406,7 @@ export class SSHShareDO {
}
const now = Date.now();
await this.appendAudit('session.closed', { normal: body.normal === true }, now);
this.updateStatus(share, 'closed', now);
await this.updateStatus(share, 'closed', now);
await this.syncOwnerMetadata(share, 'closed', { closedAt: now });
return Response.json({ success: true });
}
@@ -370,7 +419,7 @@ export class SSHShareDO {
}
const now = Date.now();
await this.appendAudit(`share.${status}`, {}, now);
this.updateStatus(share, status, now);
await this.updateStatus(share, status, now);
if (share.session_name) {
const sessionStub = this.env.SSH_SESSION.get(
this.env.SSH_SESSION.idFromName(share.session_name)
@@ -389,17 +438,89 @@ export class SSHShareDO {
return Response.json({ success: true });
}
/** 分享者清空终态会话的全部审计明细,写入墓碑事件保留追责线索。 */
private async purgeAudit(body: { ownerUserId?: number }): Promise<Response> {
const share = this.getShare();
if (!share) return jsonError('Share not found', 404);
if (!Number.isInteger(body.ownerUserId) || body.ownerUserId !== share.owner_user_id) {
return jsonError('Forbidden', 403);
}
if (!TERMINAL_SHARE_STATUSES.has(share.status)) {
return jsonError('Share session is not finished', 409);
}
await this.purgeAuditContent(share, 'share.audit_purged');
this.db.exec('UPDATE share_state SET audit_purge_due = NULL');
// 手动清空即代表不再需要自动清理:取消已排期的唤醒,避免 90 天后一次无效唤起
try {
await this.state.storage.deleteAlarm();
} catch {
/* 当前无闹钟时忽略 */
}
return Response.json({ success: true });
}
/** 清空审计明细并写入墓碑;重置 audit_bytes。手动清空与到期自动清理共用。 */
private async purgeAuditContent(share: ShareStateRow, eventType: string): Promise<void> {
const occurredAt = Date.now();
this.db.exec('DELETE FROM audit_events');
this.db.exec('UPDATE share_state SET audit_bytes = 0');
await this.appendAudit(eventType, {}, occurredAt);
await this.notifyOwnerAuditPurged(
share,
eventType === 'share.audit_auto_purged' ? 'auto' : 'manual',
occurredAt
);
}
/** 尽力同步清理留痕到所有者 UserDBDO(管理端集中展示);失败不影响已完成的清理。 */
private async notifyOwnerAuditPurged(
share: ShareStateRow,
purgeType: 'manual' | 'auto',
occurredAt: number
): Promise<void> {
try {
const stub = this.env.USER_DB.get(this.env.USER_DB.idFromName(share.owner_github_id));
const response = await stub.fetch(
new Request(`http://internal/internal/shares/${share.share_id}/audit-purged`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: share.owner_user_id,
purged_at: occurredAt,
purge_type: purgeType,
}),
})
);
if (!response.ok) {
console.error('SSHShareDO: failed to sync audit purge trace:', response.status);
}
} catch (error) {
console.error('SSHShareDO: failed to sync audit purge trace:', error);
}
}
private ownerView(ownerUserId: number, after: number, limit: number): Response {
const share = this.getShare();
if (!share || share.owner_user_id !== ownerUserId) return jsonError('Forbidden', 403);
// 清理墓碑事件(purgeAuditContent 写入的两种 event_type)不进入常规列表,
// 单独作为 removals 返回供前端折叠面板展示;SQL 字面量为编译期常量。
const events = this.db
.exec(
`SELECT id, occurred_at, event_type, details FROM audit_events
WHERE id > ? ORDER BY id ASC LIMIT ?`,
WHERE id > ?
AND event_type NOT IN ('share.audit_purged', 'share.audit_auto_purged')
ORDER BY id ASC LIMIT ?`,
after,
limit + 1
)
.toArray() as Array<{ id: number; occurred_at: number; event_type: string; details: string }>;
const removalRows = this.db
.exec(
`SELECT occurred_at, event_type FROM audit_events
WHERE event_type IN ('share.audit_purged', 'share.audit_auto_purged')
ORDER BY occurred_at DESC`
)
.toArray() as Array<{ occurred_at: number; event_type: string }>;
const hasMore = events.length > limit;
const visible = events.slice(0, limit).map((event) => ({
id: event.id,
@@ -420,6 +541,10 @@ export class SSHShareDO {
auditBytes: share.audit_bytes,
},
events: visible,
removals: removalRows.map((row) => ({
occurredAt: row.occurred_at,
eventType: row.event_type,
})),
hasMore,
nextAfter: visible.at(-1)?.id ?? after,
});
@@ -430,7 +555,11 @@ export class SSHShareDO {
return rows.length ? (rows[0] as ShareStateRow) : null;
}
private updateStatus(share: ShareStateRow, status: ShareStatus, closedAt: number): void {
private async updateStatus(
share: ShareStateRow,
status: ShareStatus,
closedAt: number
): Promise<void> {
this.db.exec(
`UPDATE share_state SET status = ?, closed_at = ?, ticket_hash = NULL,
ticket_expires_at = NULL WHERE singleton = 1`,
@@ -439,6 +568,24 @@ export class SSHShareDO {
);
share.status = status;
share.closed_at = closedAt;
// 终态进入审计保留期:到期自动清理调度;无审计明细则跳过
if (TERMINAL_SHARE_STATUSES.has(status)) {
const count = Number(this.db.exec('SELECT COUNT(*) AS count FROM audit_events').one().count);
if (count > 0) {
const retentionDays = share.audit_retention_days ?? DEFAULT_AUDIT_RETENTION_DAYS;
const due = Date.now() + retentionDays * MS_PER_DAY;
this.db.exec('UPDATE share_state SET audit_purge_due = ?', due);
try {
await this.state.storage.setAlarm(due);
share.audit_purge_due = due;
} catch (error) {
// 排期失败必须回滚:否则库里留下“有排期但无闹钟”的幽灵状态,自动清理将永不触发
console.error('SSHShareDO: failed to schedule audit purge alarm:', error);
this.db.exec('UPDATE share_state SET audit_purge_due = NULL');
share.audit_purge_due = null;
}
}
}
}
private async appendAudit(
+257 -195
View File
@@ -2,12 +2,10 @@ import { normalizeSnippetInput, SNIPPET_MAX_COUNT } from '../snippet-schema';
import {
ALLOWED_LOCATION_HINTS,
type Env,
type ServerConfig,
type SSHConnectionConfig,
type SSHJumpHostConfig,
type UserInfo,
} from '../types';
import { type InferResult, inferLocationHint } from './ip-geo';
import { inferLocationHint } from './ip-geo';
import { isDetectedOS } from './os-detect';
import { deserializeServerRow, serializeServerTags } from './server-tags';
@@ -29,6 +27,42 @@ interface StoredServerRow {
jump_server_id: number | null;
}
// ====== 行形状帮助类型:与 UserDBDO 内各 SELECT 列一一对应(配合 query<T> 消除逐处 as unknown as) ======
type UserRow = { id: number; github_id: number; username: string; avatar_url: string };
type ServerRow = {
id: number;
user_id: number;
name: string;
host: string;
port: number;
username: string;
auth_method: string;
region: string | null;
inferred_hint: string | null;
tags: string;
os: string | null;
jump_server_id: number | null;
created_at: string;
updated_at: string;
};
type UserIdRow = { user_id: number };
type JumpRow = { user_id: number; jump_server_id: number | null };
type ServerEditRow = {
user_id: number;
host: string;
port: number;
auth_method: string;
region: string | null;
inferred_hint: string | null;
jump_server_id: number | null;
};
type ServerNameRow = { name: string };
type ShareMetaRow = { user_id: number; server_id: number; share_ref: string; status: string };
type ThemeRow = { theme_data: string };
type FingerprintRow = { fingerprint: string };
type AIConfigRow = { base_url: string; model: string; api_key_last4: string; updated_at: string };
type AIConfigSecretRow = { base_url: string; model: string; api_key_enc: string };
/**
* UserDBDO — 按 GitHub 用户 ID 命名并隔离的用户数据库 Durable Object
*
@@ -39,7 +73,6 @@ interface StoredServerRow {
* - One-time-token 生成与消费(安全传递凭据)
*/
export class UserDBDO {
private state: DurableObjectState;
private env: Env;
private db: any; // SqlStorage (DO SQLite)
// one-time-token 内存存储:token → { config, expiresAt }
@@ -49,12 +82,24 @@ export class UserDBDO {
private derivedKeyCache: Map<number, CryptoKey> = new Map();
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
this.db = (state.storage as any).sql;
this.initSchema();
}
/**
* SqlStorage 无类型句柄的薄封装:行形状由调用方按 SELECT 列声明(T),
* 消除逐处 as unknown as。仅编译期 cast,运行时行为与 toArray() 完全一致。
*/
private query<T>(sql: string, ...params: unknown[]): T[] {
return this.db.exec(sql, ...params).toArray() as T[];
}
private one<T>(sql: string, ...params: unknown[]): T | null {
const rows = this.query<T>(sql, ...params);
return rows.length > 0 ? rows[0] : null;
}
private initSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS system_config (
@@ -175,6 +220,16 @@ export class UserDBDO {
this.db.exec('ALTER TABLE servers ADD COLUMN jump_server_id INTEGER DEFAULT NULL');
}
this.db.exec('CREATE INDEX IF NOT EXISTS idx_servers_jump ON servers(jump_server_id)');
// === Migration: 给既有 ssh_shares 表追加审计清理留痕列(幂等) ===
// SSHShareDO 清理审计后同步写入;管理端据此隐藏查看入口并集中展示清理记录
const shareCols = this.db.exec('PRAGMA table_info(ssh_shares)').toArray();
if (!shareCols.some((c: any) => c.name === 'audit_purged_at')) {
this.db.exec('ALTER TABLE ssh_shares ADD COLUMN audit_purged_at INTEGER DEFAULT NULL');
}
if (!shareCols.some((c: any) => c.name === 'audit_purge_type')) {
this.db.exec('ALTER TABLE ssh_shares ADD COLUMN audit_purge_type TEXT DEFAULT NULL');
}
}
async fetch(request: Request): Promise<Response> {
@@ -250,6 +305,11 @@ export class UserDBDO {
if (shareStatusMatch && request.method === 'PUT') {
return this.handleUpdateShareStatus(shareStatusMatch[1], request);
}
// /internal/shares/:id/audit-purged —— 仅由 SSHShareDO 清理审计后调用
const sharePurgedMatch = path.match(/^\/internal\/shares\/([^/]+)\/audit-purged$/);
if (sharePurgedMatch && request.method === 'POST') {
return this.handleShareAuditPurged(sharePurgedMatch[1], request);
}
// /internal/servers/:id/os —— 仅由 SSHSession(可信会话)通过 DO stub 调用
const osMatch = path.match(/^\/internal\/servers\/(\d+)\/os$/);
@@ -346,10 +406,11 @@ export class UserDBDO {
avatar_url: string;
}>();
// Upsert 用户
const existing = this.db
.exec('SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', github_id)
.toArray();
// Upsert 用户(行形状:users 的 id/github_id/username/avatar_url 列)
const existing = this.query<UserRow>(
'SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?',
github_id
);
if (existing.length > 0) {
// 更新用户信息
@@ -359,7 +420,7 @@ export class UserDBDO {
avatar_url,
github_id
);
const user = existing[0] as unknown as UserInfo;
const user = existing[0];
user.username = username;
user.avatar_url = avatar_url;
return Response.json(user);
@@ -373,9 +434,10 @@ export class UserDBDO {
avatar_url
);
const newUser = this.db
.exec('SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?', github_id)
.toArray()[0] as unknown as UserInfo;
const newUser = this.query<UserRow>(
'SELECT id, github_id, username, avatar_url FROM users WHERE github_id = ?',
github_id
)[0];
return Response.json(newUser);
}
@@ -422,20 +484,18 @@ export class UserDBDO {
private async handleSessionVerify(request: Request): Promise<Response> {
const { token } = await request.json<{ token: string }>();
const rows = this.db
.exec(
`SELECT u.id, u.github_id, u.username, u.avatar_url
FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > datetime('now')`,
token
)
.toArray();
const rows = this.query<UserRow>(
`SELECT u.id, u.github_id, u.username, u.avatar_url
FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > datetime('now')`,
token
);
if (rows.length === 0) {
return Response.json({ error: 'Invalid or expired session' }, { status: 401 });
}
return Response.json(rows[0] as unknown as UserInfo);
return Response.json(rows[0]);
}
private async handleSessionDelete(request: Request): Promise<Response> {
@@ -447,19 +507,13 @@ export class UserDBDO {
// ==================== 服务器 CRUD ====================
private handleGetServers(userId: number): Response {
const rows = this.db
.exec(
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
FROM servers WHERE user_id = ? ORDER BY updated_at DESC`,
userId
)
.toArray();
return Response.json(
rows.map((row: Record<string, unknown>) =>
deserializeServerRow(row)
) as unknown as ServerConfig[]
const rows = this.query<ServerRow>(
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
FROM servers WHERE user_id = ? ORDER BY updated_at DESC`,
userId
);
return Response.json(rows.map((row) => deserializeServerRow(row)));
}
private validateJumpChain(
@@ -481,11 +535,12 @@ export class UserDBDO {
depth++;
if (depth > MAX_JUMP_HOSTS) return `最多允许 ${MAX_JUMP_HOSTS} 级 SSH 跳转`;
const rows = this.db
.exec('SELECT user_id, jump_server_id FROM servers WHERE id = ?', currentId)
.toArray();
const rows: JumpRow[] = this.query<JumpRow>(
'SELECT user_id, jump_server_id FROM servers WHERE id = ?',
currentId
);
if (rows.length === 0) return '所选跳板服务器不存在';
const row = rows[0] as unknown as { user_id: number; jump_server_id: number | null };
const row = rows[0];
if (row.user_id !== userId) return '不能使用其他用户的服务器作为跳板';
currentId = row.jump_server_id ?? null;
}
@@ -572,18 +627,15 @@ export class UserDBDO {
);
// 获取新创建的记录
const rows = this.db
.exec(
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
FROM servers WHERE user_id = ? ORDER BY id DESC LIMIT 1`,
body.user_id
)
.toArray();
const rows = this.query<ServerRow>(
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
FROM servers WHERE user_id = ? ORDER BY id DESC LIMIT 1`,
body.user_id
);
// DEBUG_MODE 开启时,在响应中附带调试信息
const server = deserializeServerRow(
rows[0] as Record<string, unknown>
) as unknown as ServerConfig;
// 行形状由上方 SELECT 的 servers 全列定义;deserializeServerRow 归一化 tags 后直接序列化
const server = deserializeServerRow(rows[0]);
if (this.env.DEBUG_MODE === 'true') {
return Response.json({ ...server, _debug: inferDebug }, { status: 201 });
}
@@ -604,23 +656,13 @@ export class UserDBDO {
jump_server_id?: number | null;
}>();
// 验证服务器属于该用户
const existing = this.db
.exec(
'SELECT user_id, host, port, auth_method, region, inferred_hint, jump_server_id FROM servers WHERE id = ?',
serverId
)
.toArray();
// 验证服务器属于该用户(行形状:user_id/host/port/auth_method/region/inferred_hint/jump_server_id)
const existing = this.query<ServerEditRow>(
'SELECT user_id, host, port, auth_method, region, inferred_hint, jump_server_id FROM servers WHERE id = ?',
serverId
);
if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
const current = existing[0] as unknown as {
user_id: number;
host: string;
port: number;
auth_method: string;
region: string | null;
inferred_hint: string | null;
jump_server_id: number | null;
};
const current = existing[0];
if (current.user_id !== body.user_id)
return Response.json({ error: 'Forbidden' }, { status: 403 });
@@ -645,14 +687,15 @@ export class UserDBDO {
// 历史下游节点可能残留区域值;从跳板切回直连且请求未显式指定区域时,
// 应回到 Auto,而不是复用一个此前从未参与连接调度的旧值。
const requestedRegion =
body.region === undefined
? current.jump_server_id === null
? current.region
: null
: (ALLOWED_LOCATION_HINTS as readonly string[]).includes(body.region)
? body.region
: null;
// 未提供且直连:沿用旧区域;跳板连接:清空;提供但非法:视为未提供(Auto)
let requestedRegion: string | null = null;
if (body.region !== undefined) {
if ((ALLOWED_LOCATION_HINTS as readonly string[]).includes(body.region)) {
requestedRegion = body.region;
}
} else if (current.jump_server_id === null) {
requestedRegion = current.region;
}
const normalizedRegion = nextJumpServerId === null ? requestedRegion : null;
const isDirect = nextJumpServerId === null;
const becameDirect = current.jump_server_id !== null && isDirect;
@@ -744,38 +787,34 @@ export class UserDBDO {
if (currentJumpError) return Response.json({ error: currentJumpError }, { status: 400 });
updates.push("updated_at = datetime('now')");
values.push(serverId);
// SET 片段仅来自本函数硬编码白名单(列名 + '?' 或固定字面量),动态值全部经 values 参数绑定
// pi-lens-ignore: sql-injection
this.db.exec(`UPDATE servers SET ${updates.join(', ')} WHERE id = ?`, ...values);
}
const row = this.db
.exec(
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
FROM servers WHERE id = ?`,
serverId
)
.toArray();
return Response.json(
deserializeServerRow(row[0] as Record<string, unknown>) as unknown as ServerConfig
const row = this.query<ServerRow>(
`SELECT id, user_id, name, host, port, username, auth_method, region, inferred_hint, tags, os, jump_server_id, created_at, updated_at
FROM servers WHERE id = ?`,
serverId
);
return Response.json(deserializeServerRow(row[0]));
}
private async handleDeleteServer(serverId: number, request: Request): Promise<Response> {
const body = await request.json<{ user_id: number }>();
// 验证服务器属于该用户
const existing = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray();
const existing = this.query<UserIdRow>('SELECT user_id FROM servers WHERE id = ?', serverId);
if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
if ((existing[0] as unknown as { user_id: number }).user_id !== body.user_id)
return Response.json({ error: 'Forbidden' }, { status: 403 });
const ownerUserId = existing[0].user_id;
if (ownerUserId !== body.user_id) return Response.json({ error: 'Forbidden' }, { status: 403 });
const references = this.db
.exec(
'SELECT name FROM servers WHERE user_id = ? AND jump_server_id = ? ORDER BY name LIMIT 5',
body.user_id,
serverId
)
.toArray() as unknown as Array<{ name: string }>;
const references = this.query<ServerNameRow>(
'SELECT name FROM servers WHERE user_id = ? AND jump_server_id = ? ORDER BY name LIMIT 5',
body.user_id,
serverId
);
if (references.length > 0) {
return Response.json(
{
@@ -786,14 +825,12 @@ export class UserDBDO {
}
const activeShares = Number(
this.db
.exec(
`SELECT COUNT(*) AS count FROM ssh_shares
(this.one<{ count: number }>(
`SELECT COUNT(*) AS count FROM ssh_shares
WHERE user_id = ? AND server_id = ? AND status IN ('unused', 'claimed', 'active')`,
body.user_id,
serverId
)
.one().count
body.user_id,
serverId
) ?? { count: 0 }).count
);
if (activeShares > 0) {
return Response.json(
@@ -814,9 +851,10 @@ export class UserDBDO {
const body = await request.json<{ user_id: number; os: string }>();
// 验证服务器属于该用户
const existing = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray();
const existing = this.query<UserIdRow>('SELECT user_id FROM servers WHERE id = ?', serverId);
if (existing.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
if ((existing[0] as unknown as { user_id: number }).user_id !== body.user_id) {
const ownerUserId = existing[0].user_id;
if (ownerUserId !== body.user_id) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
@@ -834,9 +872,10 @@ export class UserDBDO {
if (!Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'Invalid user_id' }, { status: 400 });
}
const server = this.db.exec('SELECT user_id FROM servers WHERE id = ?', serverId).toArray();
const server = this.query<UserIdRow>('SELECT user_id FROM servers WHERE id = ?', serverId);
if (server.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
if ((server[0] as unknown as { user_id: number }).user_id !== userId) {
const ownerUserId = server[0].user_id;
if (ownerUserId !== userId) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
const now = Date.now();
@@ -851,7 +890,8 @@ export class UserDBDO {
const rows = this.db
.exec(
`SELECT id, server_id, expires_at, max_session_seconds, status,
claimed_at, active_at, closed_at, created_at
claimed_at, active_at, closed_at, created_at,
audit_purged_at, audit_purge_type
FROM ssh_shares WHERE user_id = ? AND server_id = ?
ORDER BY created_at DESC LIMIT 50`,
userId,
@@ -869,6 +909,8 @@ export class UserDBDO {
activeAt: row.active_at,
closedAt: row.closed_at,
createdAt: row.created_at,
auditPurgedAt: row.audit_purged_at ?? null,
auditPurgeType: row.audit_purge_type ?? null,
}))
);
}
@@ -915,14 +957,12 @@ export class UserDBDO {
const validation = this.validateShareableServer(serverId, body.user_id);
if (validation instanceof Response) return validation;
const activeCount = Number(
this.db
.exec(
`SELECT COUNT(*) AS count FROM ssh_shares
(this.one<{ count: number }>(
`SELECT COUNT(*) AS count FROM ssh_shares
WHERE user_id = ? AND status IN ('unused', 'claimed', 'active') AND expires_at > ?`,
body.user_id,
now
)
.one().count
body.user_id,
now
) ?? { count: 0 }).count
);
if (activeCount >= 10) {
return Response.json(
@@ -967,9 +1007,9 @@ export class UserDBDO {
return Response.json({ error: '跳板服务器关系存在循环' }, { status: 400 });
}
seen.add(currentId);
const rows = this.db.exec('SELECT * FROM servers WHERE id = ?', currentId).toArray();
const rows: StoredServerRow[] = this.query<StoredServerRow>('SELECT * FROM servers WHERE id = ?', currentId);
if (rows.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
const row = rows[0] as unknown as StoredServerRow;
const row = rows[0];
if (row.user_id !== userId) return Response.json({ error: 'Forbidden' }, { status: 403 });
reversed.push(row);
if (reversed.length > MAX_JUMP_HOSTS + 1) {
@@ -1046,9 +1086,10 @@ export class UserDBDO {
if (!Number.isInteger(body.user_id) || !allowed.has(body.status)) {
return Response.json({ error: 'Invalid share status update' }, { status: 400 });
}
const rows = this.db.exec('SELECT user_id FROM ssh_shares WHERE id = ?', shareId).toArray();
const rows = this.query<UserIdRow>('SELECT user_id FROM ssh_shares WHERE id = ?', shareId);
if (rows.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 });
if ((rows[0] as unknown as { user_id: number }).user_id !== body.user_id) {
const ownerUserId = rows[0].user_id;
if (ownerUserId !== body.user_id) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
this.db.exec(
@@ -1066,6 +1107,35 @@ export class UserDBDO {
return Response.json({ success: true });
}
/** SSHShareDO 清理审计后同步留痕:记录清理时间与方式,供管理端集中展示。 */
private async handleShareAuditPurged(shareId: string, request: Request): Promise<Response> {
const body = await request.json<{
user_id: number;
purged_at: number;
purge_type: string;
}>();
if (
!Number.isInteger(body.user_id) ||
!Number.isFinite(body.purged_at) ||
(body.purge_type !== 'manual' && body.purge_type !== 'auto')
) {
return Response.json({ error: 'Invalid audit purge update' }, { status: 400 });
}
const rows = this.query<UserIdRow>('SELECT user_id FROM ssh_shares WHERE id = ?', shareId);
if (rows.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 });
const ownerUserId = rows[0].user_id;
if (ownerUserId !== body.user_id) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
this.db.exec(
'UPDATE ssh_shares SET audit_purged_at = ?, audit_purge_type = ? WHERE id = ?',
body.purged_at,
body.purge_type,
shareId
);
return Response.json({ success: true });
}
private async handleShareConnectionConfig(serverId: number, request: Request): Promise<Response> {
const body = await request.json<{
user_id: number;
@@ -1076,19 +1146,12 @@ export class UserDBDO {
if (!body.share_id || !body.share_ref || !Number.isFinite(body.session_expires_at)) {
return Response.json({ error: 'Invalid share session policy' }, { status: 400 });
}
const metadata = this.db
.exec(
`SELECT user_id, server_id, share_ref, status FROM ssh_shares WHERE id = ?`,
body.share_id
)
.toArray();
const metadata = this.query<ShareMetaRow>(
`SELECT user_id, server_id, share_ref, status FROM ssh_shares WHERE id = ?`,
body.share_id
);
if (metadata.length === 0) return Response.json({ error: 'Share not found' }, { status: 404 });
const row = metadata[0] as unknown as {
user_id: number;
server_id: number;
share_ref: string;
status: string;
};
const row = metadata[0];
if (
row.user_id !== body.user_id ||
row.server_id !== serverId ||
@@ -1147,9 +1210,7 @@ export class UserDBDO {
// ==================== 用户自定义主题 ====================
private handleGetTheme(userId: number): Response {
const rows = this.db
.exec('SELECT theme_data FROM user_themes WHERE user_id = ?', userId)
.toArray();
const rows = this.query<ThemeRow>('SELECT theme_data FROM user_themes WHERE user_id = ?', userId);
if (rows.length === 0) {
return Response.json({ theme: null });
@@ -1157,7 +1218,8 @@ export class UserDBDO {
try {
return Response.json({
theme: JSON.parse((rows[0] as unknown as { theme_data: string }).theme_data),
// 行形状由上方 SELECT('theme_data') 定义,非法 JSON 由 catch 兜底为 null
theme: JSON.parse(rows[0].theme_data),
});
} catch {
return Response.json({ theme: null });
@@ -1191,9 +1253,9 @@ export class UserDBDO {
return Response.json({ error: '跳板服务器关系存在循环' }, { status: 400 });
}
seen.add(currentId);
const rows = this.db.exec('SELECT * FROM servers WHERE id = ?', currentId).toArray();
const rows: StoredServerRow[] = this.query<StoredServerRow>('SELECT * FROM servers WHERE id = ?', currentId);
if (rows.length === 0) return Response.json({ error: 'Server not found' }, { status: 404 });
const row = rows[0] as unknown as StoredServerRow;
const row = rows[0];
if (row.user_id !== body.user_id)
return Response.json({ error: 'Forbidden' }, { status: 403 });
reversed.push(row);
@@ -1212,22 +1274,18 @@ export class UserDBDO {
index === 0 ? server.host : `jump:${pathSegments.join('>')}|${server.host}`;
pathSegments.push(`${server.id}@${server.host}:${server.port}`);
const credential = await this.decryptCredential(server.credential, body.user_id);
const khRows = this.db
.exec(
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
body.user_id,
identity,
server.port
)
.toArray();
const khRows = this.query<FingerprintRow>(
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
body.user_id,
identity,
server.port
);
return {
server,
identity,
credential,
expectedFingerprint:
khRows.length > 0
? (khRows[0] as unknown as { fingerprint: string }).fingerprint
: undefined,
khRows.length > 0 ? khRows[0].fingerprint : undefined,
};
})
);
@@ -1412,20 +1470,18 @@ export class UserDBDO {
private handleGetKnownHosts(userId: number, host: string | null, port: string | null): Response {
if (host && port) {
// 查询特定 host:port 的指纹
const rows = this.db
.exec(
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
userId,
host,
parseInt(port)
)
.toArray();
if (rows.length === 0) {
return Response.json({ fingerprint: null });
}
return Response.json({
fingerprint: (rows[0] as unknown as { fingerprint: string }).fingerprint,
});
const rows = this.query<FingerprintRow>(
'SELECT fingerprint FROM known_hosts WHERE user_id = ? AND host = ? AND port = ?',
userId,
host,
parseInt(port)
);
if (rows.length === 0) {
return Response.json({ fingerprint: null });
}
return Response.json({
fingerprint: rows[0].fingerprint,
});
}
// 列出所有已知主机
@@ -1483,10 +1539,12 @@ export class UserDBDO {
// ==================== 命令片段管理 ====================
private handleGetSnippets(userId: number): Response {
const rows = this.db.exec(
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id ASC',
userId,
).toArray();
const rows = this.db
.exec(
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id ASC',
userId
)
.toArray();
return Response.json(rows);
}
@@ -1496,10 +1554,9 @@ export class UserDBDO {
if (!normalized.ok) {
return Response.json({ error: normalized.error }, { status: 400 });
}
const countRows = this.db.exec(
'SELECT COUNT(*) AS count FROM command_snippets WHERE user_id = ?',
body.user_id,
).toArray();
const countRows = this.db
.exec('SELECT COUNT(*) AS count FROM command_snippets WHERE user_id = ?', body.user_id)
.toArray();
const count = Number((countRows[0] as { count?: unknown } | undefined)?.count ?? 0);
if (count >= SNIPPET_MAX_COUNT) {
return Response.json({ error: 'limitReached' }, { status: 400 });
@@ -1508,12 +1565,14 @@ export class UserDBDO {
`INSERT INTO command_snippets (user_id, name, command, created_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))`,
body.user_id,
normalized.value.name,
normalized.value.command,
normalized.value.command
);
const rows = this.db.exec(
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id DESC LIMIT 1',
body.user_id,
).toArray();
const rows = this.db
.exec(
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? ORDER BY id DESC LIMIT 1',
body.user_id
)
.toArray();
return Response.json(rows[0] ?? { success: true }, { status: 201 });
}
@@ -1523,12 +1582,26 @@ export class UserDBDO {
if (!normalized.ok) {
return Response.json({ error: normalized.error }, { status: 400 });
}
const existing = this.db.exec('SELECT id FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId).toArray();
const existing = this.db
.exec('SELECT id FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId)
.toArray();
if (existing.length === 0) {
return Response.json({ error: 'notFound' }, { status: 404 });
}
this.db.exec(`UPDATE command_snippets SET name = ?, command = ?, updated_at = datetime('now') WHERE user_id = ? AND id = ?`, normalized.value.name, normalized.value.command, body.user_id, snippetId);
const rows = this.db.exec('SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? AND id = ?', body.user_id, snippetId).toArray();
this.db.exec(
`UPDATE command_snippets SET name = ?, command = ?, updated_at = datetime('now') WHERE user_id = ? AND id = ?`,
normalized.value.name,
normalized.value.command,
body.user_id,
snippetId
);
const rows = this.db
.exec(
'SELECT id, name, command, created_at, updated_at FROM command_snippets WHERE user_id = ? AND id = ?',
body.user_id,
snippetId
)
.toArray();
return Response.json(rows[0] ?? { success: true });
}
@@ -1541,23 +1614,16 @@ export class UserDBDO {
// ==================== AI 配置管理 ====================
private handleGetAIConfig(userId: number): Response {
const rows = this.db
.exec(
'SELECT base_url, model, api_key_last4, updated_at FROM ai_configs WHERE user_id = ?',
userId
)
.toArray();
const rows = this.query<AIConfigRow>(
'SELECT base_url, model, api_key_last4, updated_at FROM ai_configs WHERE user_id = ?',
userId
);
if (rows.length === 0) {
return Response.json({ configured: false });
}
const row = rows[0] as unknown as {
base_url: string;
model: string;
api_key_last4: string;
updated_at: string;
};
const row = rows[0];
return Response.json({
configured: true,
base_url: row.base_url,
@@ -1631,19 +1697,15 @@ export class UserDBDO {
}
private async handleGetAIConfigDecrypted(userId: number): Promise<Response> {
const rows = this.db
.exec('SELECT base_url, model, api_key_enc FROM ai_configs WHERE user_id = ?', userId)
.toArray();
const rows = this.query<AIConfigSecretRow>(
'SELECT base_url, model, api_key_enc FROM ai_configs WHERE user_id = ?',
userId
);
if (rows.length === 0) {
return Response.json({ error: 'No AI config found' }, { status: 404 });
}
const row = rows[0] as unknown as {
base_url: string;
model: string;
api_key_enc: string;
};
const row = rows[0];
if (!row.api_key_enc) {
return Response.json({ error: 'No API key configured' }, { status: 404 });
+6
View File
@@ -55,6 +55,12 @@ pnpm run test:e2e
pnpm run verify
```
> **测试类型检查**:`pnpm run typecheck` 的 worker 阶段会依次执行
> `tsc --noEmit`(src)、`tsc -p tests/tsconfig.worker.json`(tests/worker|ssh|build,
> workers-types 环境)与 `tsc -p tests/tsconfig.frontend.json`(tests 根目录 *.test.ts + e2e,
> DOM 环境)。两个测试配置分开的原因:`@cloudflare/workers-types` 声明的基础 DOM 接口
> (如 Element.append)与 DOM lib 合并冲突,不能在同一项目里同时加载。
## 当前覆盖范围
### SSH 协议
+11 -8
View File
@@ -1,17 +1,20 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const packageJson = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
) as {
// 以测试文件本身为锚点解析仓库相对路径(workers-types 的全局 URL 与 node:url.URL
// 存在类型差异,这里统一走 fileURLToPath + path.join,避免跨类型使用)。
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readRootFile = (relative: string): string =>
readFileSync(join(rootDir, relative), 'utf8');
const packageJson = JSON.parse(readRootFile('package.json')) as {
packageManager?: string;
devDependencies: Record<string, string>;
};
const buildScript = readFileSync(new URL('../../scripts/build-html.js', import.meta.url), 'utf8');
const deployWorkflow = readFileSync(
new URL('../../.github/workflows/deploy.yml', import.meta.url),
'utf8'
);
const buildScript = readRootFile('scripts/build-html.js');
const deployWorkflow = readRootFile('.github/workflows/deploy.yml');
describe('reproducible build and deployment gate', () => {
it('pins the package manager and matching Vitest packages', () => {
+6 -3
View File
@@ -128,9 +128,12 @@ test('Turnstile 跟随 Standard Light 和后续主题切换', async ({ page }) =
.toEqual([{ id: 'widget-1', theme: 'light' }]);
await page.evaluate(() => {
const selector = document.getElementById('theme-selector') as HTMLSelectElement;
selector.value = 'standard-dark';
selector.dispatchEvent(new Event('change', { bubbles: true }));
const themeSelector = document.getElementById('theme-selector');
if (!(themeSelector instanceof HTMLSelectElement)) {
throw new Error('theme-selector not found');
}
themeSelector.value = 'standard-dark';
themeSelector.dispatchEvent(new Event('change', { bubbles: true }));
});
await expect
+8
View File
@@ -0,0 +1,8 @@
/**
* 仅为 DOM 配置的测试类型检查提供最小 shape:src/types.ts 的 Env 接口
* 引用 Workers 全局类 DurableObjectNamespace。该全局类型来自
* @cloudflare/workers-types,但它声明的基础 DOM 接口(如 Element.append)
* 与 DOM lib 合并冲突,不能在 DOM 配置中同时加载,故以最小声明替代。
* Env 在测试中仅作类型引用、从不实例化,不影响检查真实性。
*/
declare class DurableObjectNamespace<T = unknown> {}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"types": ["node", "vitest/globals"],
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["./*.test.ts", "./e2e/**/*.ts", "./global-shim.d.ts"],
"exclude": ["node_modules", "dist", "../frontend"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"types": ["@cloudflare/workers-types", "node", "vitest/globals"],
"lib": ["ES2022"]
},
"include": ["./worker/**/*.ts", "./ssh/**/*.ts", "./build/**/*.ts"],
"exclude": ["node_modules", "dist", "../frontend"]
}
+3 -1
View File
@@ -17,7 +17,9 @@ function makeExecutor(execResult: {
stderr: string;
exitCode: number;
}) {
const execCommand = vi.fn(async () => execResult);
const execCommand = vi.fn(
async (command: string): Promise<typeof execResult> => execResult
);
const askConfirmation = vi.fn(async () => true);
const executor = new ToolExecutor(
new TerminalContext(),
+4 -1
View File
@@ -3,7 +3,10 @@ import { SSHSession } from '../../src/worker/ssh-session';
function createSession(stdout: string) {
const send = vi.fn();
const fetch = vi.fn(async () => Response.json({ success: true }));
const fetch = vi.fn(
async (input: string | URL | Request, init?: RequestInit): Promise<Response> =>
Response.json({ success: true })
);
const env = {
USER_DB: {
idFromName: vi.fn(() => 'user-db-id'),
+12 -7
View File
@@ -290,7 +290,9 @@ describe('安全 — 强制 GitHub 登录模式', () => {
REQUIRE_GITHUB_AUTH: value,
})
);
expect((await res.json()).githubAuthRequired).toBe(expected);
expect(
((await res.json()) as { githubAuthRequired?: boolean }).githubAuthRequired
).toBe(expected);
});
it('强制登录时拒绝匿名 SSH WebSocket', async () => {
@@ -408,7 +410,6 @@ describe('安全 — 越权防护(IDOR)', () => {
}
// DO 收到 PUT /internal/servers/:id,检查 belong,属于他人 → 403
if (req.url.match(/\/internal\/servers\/\d+$/) && req.method === 'PUT') {
const body = await req.json();
// 模拟:服务器 record.user_id=2 !== body.user_id=1
return new Response(JSON.stringify({ error: 'Server does not belong to user' }), {
status: 403,
@@ -623,7 +624,7 @@ describe('安全 — SSRF 接缝(AI base_url)', () => {
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBeTruthy(); // validateBaseUrl 返回的中文 reason
expect((data as { error?: string }).error).toBeTruthy(); // validateBaseUrl 返回的中文 reason
// 不应到达 DO 持久化
expect(env.USER_DB.get({} as any).fetch).not.toHaveBeenCalledWith(
expect.objectContaining({ url: expect.stringContaining('/internal/ai-config') })
@@ -971,7 +972,7 @@ describe('安全 — 一次性 token 与接缝鉴权', () => {
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toMatch(/token|无效|expired/i);
expect((data as { error?: string }).error).toMatch(/token|无效|expired/i);
});
it('SFTP attach 缺 session 参数 → 403 Missing SFTP attach token', async () => {
@@ -990,7 +991,7 @@ describe('安全 — 一次性 token 与接缝鉴权', () => {
expect(res.status).toBe(403);
const data = await res.json();
expect(data.error).toMatch(/token|missing/i);
expect((data as { error?: string }).error).toMatch(/token|missing/i);
});
});
@@ -998,13 +999,17 @@ describe('安全 — 一次性 SSH 分享边界', () => {
it('分享功能默认关闭,仅显式配置 true 时公开', async () => {
const worker = await loadWorker();
const disabled = await worker.fetch(makeRequest('/api/config'), makeEnv());
expect((await disabled.json()).sshSharingEnabled).toBe(false);
expect(((await disabled.json()) as { sshSharingEnabled?: boolean }).sshSharingEnabled).toBe(
false
);
const enabled = await worker.fetch(
makeRequest('/api/config'),
makeEnv({ ENABLE_SSH_SHARING: 'true' })
);
expect((await enabled.json()).sshSharingEnabled).toBe(true);
expect(((await enabled.json()) as { sshSharingEnabled?: boolean }).sshSharingEnabled).toBe(
true
);
});
it('公开领取接口对非法 JSON 和非 URL-safe 凭证返回 400', async () => {
+3 -2
View File
@@ -30,7 +30,8 @@ if (typeof (globalThis as any).WebSocketPair === 'undefined') {
const OriginalResponse = globalThis.Response;
class MockResponse extends OriginalResponse {
private _mockStatus: number;
public webSocket?: any;
// 基类 Response 要求 webSocket 为必填只读属性;此 Mock 在 101 升级时赋值。
public webSocket: any;
constructor(body?: any, init?: any) {
if (init && init.status === 101) {
@@ -91,7 +92,7 @@ describe('SSHSession Re-attach & Backpressure Flow Control', () => {
const internal = session as any;
internal.state = 'ready';
const channel = new SSHChannel(0, 1000, 1024 * 1024, 32768);
const channel = new SSHChannel();
internal.shellChannel = channel;
internal.channels.set(0, channel);
+4 -4
View File
@@ -1,4 +1,6 @@
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { SSHChannel } from '../../src/ssh/channel';
import {
@@ -37,10 +39,8 @@ function createHandler(sftpOverrides: Record<string, unknown>) {
describe('SFTP 同名上传保护', () => {
it('SSH 会话仅接受布尔 true 作为显式覆盖授权', () => {
const source = readFileSync(
new URL('../../src/worker/ssh-session.ts', import.meta.url),
'utf8'
);
const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const source = readFileSync(join(rootDir, 'src/worker/ssh-session.ts'), 'utf8');
expect(source).toContain(
'this.sftpHandler.uploadStart(msg.path, msg.size || 0, msg.overwrite === true)'
);
+376
View File
@@ -0,0 +1,376 @@
import { describe, expect, it, vi } from 'vitest';
import { SSHShareDO } from '../../src/worker/share-do';
import { UserDBDO } from '../../src/worker/user-db';
// ==================== SSHShareDO 侧:内存伪 SQL ====================
interface FakeAuditEvent {
id: number;
occurred_at: number;
event_type: string;
details: string;
byte_size: number;
}
function seedShare(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
singleton: 1,
share_id: 'share-1',
token_hash: 'token-hash',
owner_user_id: 7,
owner_github_id: 'gh-7',
server_id: 1,
server_name: 'srv-1',
expires_at: Date.now() - 60_000,
max_session_seconds: 600,
status: 'closed',
claimed_at: 1000,
active_at: 2000,
closed_at: 3000,
session_expires_at: null,
session_name: null,
ticket_hash: null,
ticket_expires_at: null,
audit_bytes: 500,
device_pub_key: null,
audit_purge_due: null,
audit_retention_days: 30,
...overrides,
};
}
class ShareFakeSql {
share: Record<string, unknown> | null;
events: FakeAuditEvent[];
calls: string[] = [];
private nextId = 1;
constructor(share: Record<string, unknown> | null, events: FakeAuditEvent[] = []) {
this.share = share ? { ...share } : null;
this.events = events.map((event) => ({ ...event }));
}
exec(sql: string, ...values: unknown[]): { toArray: () => unknown[]; one: () => unknown } {
this.calls.push(sql);
const tableRead = {
toArray: () => [] as unknown[],
one: () => ({}) as unknown,
};
if (sql.includes('SELECT * FROM share_state WHERE singleton = 1')) {
return { toArray: () => (this.share ? [{ ...this.share }] : []), one: () => ({}) };
}
if (sql.includes('SELECT COUNT(*) AS count FROM audit_events')) {
return { toArray: () => [], one: () => ({ count: this.events.length }) };
}
if (sql.includes('event_type NOT IN')) {
const after = Number(values[0]);
const limit = Number(values[1]);
const rows = this.events
.filter(
(event) =>
event.id > after &&
event.event_type !== 'share.audit_purged' &&
event.event_type !== 'share.audit_auto_purged'
)
.slice(0, limit);
return { toArray: () => rows, one: () => ({}) };
}
if (sql.includes('WHERE event_type IN')) {
const rows = this.events.filter(
(event) =>
event.event_type === 'share.audit_purged' ||
event.event_type === 'share.audit_auto_purged'
);
return { toArray: () => rows, one: () => ({}) };
}
if (sql.includes('DELETE FROM audit_events')) {
this.events = [];
return tableRead;
}
if (sql.includes('UPDATE share_state SET audit_bytes = 0')) {
if (this.share) this.share.audit_bytes = 0;
return tableRead;
}
if (sql.includes('UPDATE share_state SET audit_bytes = audit_bytes +')) {
if (this.share) this.share.audit_bytes = Number(this.share.audit_bytes ?? 0) + Number(values[0]);
return tableRead;
}
if (sql.includes('UPDATE share_state SET audit_purge_due = NULL')) {
if (this.share) this.share.audit_purge_due = null;
return tableRead;
}
if (sql.includes('UPDATE share_state SET audit_purge_due = ?')) {
if (this.share) this.share.audit_purge_due = Number(values[0]);
return tableRead;
}
if (sql.includes('UPDATE share_state SET status = ?')) {
if (this.share) {
this.share.status = String(values[0]);
this.share.closed_at = Number(values[1]);
}
return tableRead;
}
if (sql.includes('INSERT INTO audit_events')) {
this.events.push({
id: this.nextId++,
occurred_at: Number(values[0]),
event_type: String(values[1]),
details: String(values[2]),
byte_size: Number(values[3]),
});
return tableRead;
}
if (sql.startsWith('CREATE') || sql.startsWith('ALTER')) {
// 建表/迁移语句:伪 SQL 中直接忽略
return tableRead;
}
return tableRead;
}
}
function makeShareDo(options: {
share: Record<string, unknown> | null;
events?: FakeAuditEvent[];
userDbFetch?: (request: Request) => Promise<Response> | Response;
deleteAlarm?: () => Promise<void>;
}): {
doInstance: SSHShareDO;
fakeSql: ShareFakeSql;
userDbFetch: ReturnType<typeof vi.fn>;
deleteAlarm: ReturnType<typeof vi.fn>;
} {
const fakeSql = new ShareFakeSql(options.share, options.events ?? []);
const userDbFetch = vi.fn(
options.userDbFetch ?? (async () => new Response('{}', { status: 200 }) as Response)
);
const deleteAlarm = vi.fn(options.deleteAlarm ?? (async () => {}));
const state = {
storage: {
sql: fakeSql,
setAlarm: vi.fn(async () => {}),
deleteAlarm,
},
};
const env = {
USER_DB: {
get: vi.fn(() => ({ fetch: userDbFetch })),
idFromName: vi.fn((name: string) => name),
},
};
const doInstance = new SSHShareDO(state as unknown as DurableObjectState, env as never);
return { doInstance, fakeSql, userDbFetch, deleteAlarm };
}
function ownerViewRequest(ownerUserId: number): Request {
return new Request(
`http://internal/internal/owner-view?owner_user_id=${ownerUserId}&after=0&limit=500`,
{ method: 'GET' }
);
}
function purgeRequest(ownerUserId: number): Request {
return new Request('http://internal/internal/audit/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ownerUserId }),
});
}
async function readOwnerView(response: Response): Promise<{
share: { auditBytes: number; status: string };
events: Array<{ eventType: string }>;
removals: Array<{ occurredAt: number; eventType: string }>;
}> {
return (await response.json()) as {
share: { auditBytes: number; status: string };
events: Array<{ eventType: string }>;
removals: Array<{ occurredAt: number; eventType: string }>;
};
}
describe('SSHShareDO 审计清理留痕', () => {
const sampleEvents: FakeAuditEvent[] = [
{ id: 1, occurred_at: 1000, event_type: 'share.claimed', details: '{}', byte_size: 2 },
{ id: 2, occurred_at: 2000, event_type: 'session.connecting', details: '{}', byte_size: 2 },
{ id: 3, occurred_at: 3000, event_type: 'terminal.output', details: '{}', byte_size: 2 },
];
it('手动清空后:墓碑不进常规事件列表,removals 返回 1 条,audit_bytes 归零', async () => {
const { doInstance } = makeShareDo({ share: seedShare(), events: sampleEvents });
const purge = await doInstance.fetch(purgeRequest(7));
expect(purge.status).toBe(200);
const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7)));
// 清理后 audit_bytes 归零后仅剩墓碑本身('{}' 恰为 2 字节)
expect(view.share.auditBytes).toBe(2);
expect(view.events).toHaveLength(0);
expect(view.events.some((event) => event.eventType === 'share.audit_purged')).toBe(false);
expect(view.removals).toHaveLength(1);
expect(view.removals[0].eventType).toBe('share.audit_purged');
});
it('手动清空失败时不产生任何清理留痕(归属校验 403、非终态 409)', async () => {
const { doInstance } = makeShareDo({ share: seedShare(), events: sampleEvents });
expect((await doInstance.fetch(purgeRequest(8))).status).toBe(403);
expect((await doInstance.fetch(purgeRequest(7))).status).toBe(200);
const unused = makeShareDo({
share: seedShare({ status: 'unused', server_name: 'srv-x' }),
events: [],
});
expect((await unused.doInstance.fetch(purgeRequest(7))).status).toBe(409);
});
it('部署窗口期 UserDBDO 同步失败不影响清理成功(尽力而为)', async () => {
const { doInstance } = makeShareDo({
share: seedShare(),
events: sampleEvents,
userDbFetch: async () => {
throw new Error('old UserDBDO: route not found');
},
});
const purge = await doInstance.fetch(purgeRequest(7));
expect(purge.status).toBe(200);
const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7)));
expect(view.removals).toHaveLength(1);
expect(view.removals[0].eventType).toBe('share.audit_purged');
});
it('到期自动清理:走 alarm 路径、写 auto 墓碑、清空 purge_due 并同步 auto 留痕', async () => {
const { doInstance, userDbFetch } = makeShareDo({
share: seedShare({ audit_purge_due: Date.now() - 1000 }),
events: sampleEvents,
});
await doInstance.alarm();
const view = await readOwnerView(await doInstance.fetch(ownerViewRequest(7)));
expect(view.removals).toHaveLength(1);
expect(view.removals[0].eventType).toBe('share.audit_auto_purged');
// 清除后仅剩自动清理墓碑自身的字节数
expect(view.share.auditBytes).toBe(2);
// notifyOwnerAuditPurged 应以 auto 方式同步到 UserDBDO
expect(userDbFetch).toHaveBeenCalledTimes(1);
const [callRequest] = userDbFetch.mock.calls[0] as [Request];
expect(callRequest.url).toContain('/internal/shares/share-1/audit-purged');
const callBody = JSON.parse(await callRequest.text()) as { purge_type: string };
expect(callBody.purge_type).toBe('auto');
});
it('updateStatus 排期闹钟失败时回滚 audit_purge_due(修复幽灵状态)', async () => {
const { doInstance, fakeSql } = makeShareDo({
share: seedShare({ status: 'claimed', session_expires_at: 1, audit_retention_days: 30 }),
events: sampleEvents,
});
// setAlarm 拒绝:模拟闹钟设置静默失败
const state = (doInstance as unknown as { state: { storage: { setAlarm: ReturnType<typeof vi.fn> } } })
.state;
state.storage.setAlarm = vi.fn(async () => {
throw new Error('alarm quota exceeded');
});
const res = await doInstance.fetch(
new Request('http://internal/internal/session/closed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ normal: true }),
})
);
expect(res.status).toBe(200);
// 失败必须回滚:库里不留“有排期但无闹钟”的幽灵状态
expect(fakeSql.share?.status).toBe('closed');
expect(fakeSql.share?.audit_purge_due).toBeNull();
});
});
// ==================== UserDBDO 侧 ====================
class UserDbFakeSql {
shareColumns: string[];
serverColumns = ['region', 'inferred_hint', 'tags', 'os', 'jump_server_id'];
shareRows: Array<{ user_id: number }>;
updates: Array<{ sql: string; values: unknown[] }> = [];
constructor(shareColumns: string[], shareRows: Array<{ user_id: number }> = []) {
this.shareColumns = shareColumns;
this.shareRows = shareRows;
}
exec(sql: string, ...values: unknown[]): { toArray: () => unknown[]; one: () => unknown } {
if (sql.includes('PRAGMA table_info(servers)')) {
return { toArray: () => this.serverColumns.map((name) => ({ name })), one: () => ({}) };
}
if (sql.includes('PRAGMA table_info(ssh_shares)')) {
return { toArray: () => this.shareColumns.map((name) => ({ name })), one: () => ({}) };
}
if (sql === 'SELECT user_id FROM ssh_shares WHERE id = ?') {
return { toArray: () => this.shareRows, one: () => ({}) };
}
if (sql.startsWith('ALTER TABLE ssh_shares')) {
this.updates.push({ sql, values });
return { toArray: () => [], one: () => ({}) };
}
if (sql.startsWith('UPDATE ssh_shares SET audit_purged_at')) {
this.updates.push({ sql, values });
return { toArray: () => [], one: () => ({}) };
}
return { toArray: () => [], one: () => ({}) };
}
}
function makeUserDb(sql: UserDbFakeSql): UserDBDO {
return new UserDBDO(
{ storage: { sql } } as unknown as DurableObjectState,
{} as never
);
}
function auditPurgedRequest(body: unknown): Request {
return new Request('http://internal/internal/shares/share-1/audit-purged', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('UserDBDO 审计清理留痕接缝', () => {
it('同步留痕:归属匹配时更新 audit_purged_at/audit_purge_type', async () => {
const sql = new UserDbFakeSql(
['id', 'user_id', 'audit_purged_at', 'audit_purge_type'],
[{ user_id: 7 }]
);
const db = makeUserDb(sql);
const res = await db.fetch(
auditPurgedRequest({ user_id: 7, purged_at: 123456, purge_type: 'manual' })
);
expect(res.status).toBe(200);
expect(sql.updates).toHaveLength(1);
expect(sql.updates[0].values).toEqual([123456, 'manual', 'share-1']);
});
it('归属不匹配拒绝写入(403),非法 purge_type 拒绝(400)', async () => {
const sql = new UserDbFakeSql(
['id', 'user_id', 'audit_purged_at', 'audit_purge_type'],
[{ user_id: 7 }]
);
const db = makeUserDb(sql);
expect((await db.fetch(auditPurgedRequest({ user_id: 8, purged_at: 1, purge_type: 'manual' }))).status).toBe(403);
expect((await db.fetch(auditPurgedRequest({ user_id: 7, purged_at: 1, purge_type: 'manual' }))).status).toBe(200);
const sql2 = new UserDbFakeSql(['id', 'user_id'], [{ user_id: 7 }]);
const db2 = makeUserDb(sql2);
expect((await db2.fetch(auditPurgedRequest({ user_id: 7, purged_at: 1, purge_type: 'weird' }))).status).toBe(400);
expect((await db2.fetch(auditPurgedRequest({ user_id: '7', purged_at: 1, purge_type: 'manual' }))).status).toBe(400);
expect((await db2.fetch(auditPurgedRequest({ user_id: 7, purged_at: Number.NaN, purge_type: 'manual' }))).status).toBe(400);
});
it('ssh_shares audit 留痕迁移幂等:缺列时补列,已有时不再 ALTER', async () => {
const oldSql = new UserDbFakeSql(['id', 'user_id'], []);
makeUserDb(oldSql);
const alterCount = oldSql.updates.filter((update) => update.sql.startsWith('ALTER')).length;
expect(alterCount).toBe(2); // audit_purged_at + audit_purge_type
const newSql = new UserDbFakeSql(['id', 'user_id', 'audit_purged_at', 'audit_purge_type'], []);
makeUserDb(newSql);
expect(newSql.updates.filter((update) => update.sql.startsWith('ALTER')).length).toBe(0);
});
});
+7 -4
View File
@@ -10,8 +10,8 @@ const globalShim = globalThis as any;
if (!globalShim.__cloudsshWsTestShimInstalled) {
globalShim.__cloudsshWsTestShimInstalled = true;
if (typeof globalThis.WebSocketPair === 'undefined') {
globalThis.WebSocketPair = class {
if (typeof (globalThis as any).WebSocketPair === 'undefined') {
(globalThis as any).WebSocketPair = class {
0: any;
1: any;
constructor() {
@@ -29,7 +29,8 @@ if (!globalShim.__cloudsshWsTestShimInstalled) {
const OriginalResponse = globalThis.Response;
class MockResponse extends OriginalResponse {
private _mockStatus: number;
public webSocket?: any;
// 基类 Response 要求 webSocket 为必填只读属性;此 Mock 在 101 升级时赋值。
public webSocket: any;
constructor(body?: any, init?: any) {
if (init && init.status === 101) {
@@ -70,7 +71,9 @@ async function makeDeviceKeyPair(): Promise<DeviceKeyPair> {
'sign',
'verify',
])) as CryptoKeyPair;
const spki = new Uint8Array(await crypto.subtle.exportKey('spki', kp.publicKey));
const spki = new Uint8Array(
(await crypto.subtle.exportKey('spki', kp.publicKey)) as ArrayBuffer
);
return { privateKey: kp.privateKey as CryptoKey, publicKeyB64: toBase64Url(spki) };
}