feat: refine management UI, connection diagnostics and local startup
continuous-integration/drone Build is failing

Migrate the workspace to React and Ant Design, improve account forms and responsive layouts, support outbound diagnostics and SSH fingerprint confirmation, and persist the management startup port. Preserve database migrations, remove S3 settings storage, and improve gateway audit handling.
This commit is contained in:
曾志威
2026-10-13 19:43:23 +08:00
parent 1a05cd359f
commit 9da95beb42
306 changed files with 11828 additions and 10618 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ func TestRestoreCommandRejectsRunningService(t *testing.T) {
t.Fatal(err)
}
defer prepared.Close()
s, err := store.OpenPortable(path, nil)
s, err := store.OpenPortable(path)
if err != nil {
t.Fatal(err)
}
+6 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@@ -21,8 +22,12 @@ import (
// runManagement owns the listener, desktop setup, serving and graceful shutdown.
func runManagement(s *store.Store, m *gateway.Manager, local *desktop.Config, authEnabled bool) error {
port, err := s.StartupPort()
if err != nil {
return fmt.Errorf("cannot load startup port: %w", err)
}
server := api.New(s, m, strings.EqualFold(os.Getenv("LINKPORT_SECURE_COOKIE"), "true"), authEnabled)
srv := &http.Server{Addr: env("LINKPORT_HTTP_ADDR", "127.0.0.1:0"), Handler: server.Handler(web.Handler()), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 16}
srv := &http.Server{Addr: env("LINKPORT_HTTP_ADDR", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))), Handler: server.Handler(web.Handler()), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 45 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 16}
if local != nil || !authEnabled {
host, _, e := net.SplitHostPort(srv.Addr)
if e != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
+9
View File
@@ -1,5 +1,14 @@
# 全模块设计模式与扩展边界
## 当前 React + Ant Design(2026-10-04)
按用户最新要求,生产前端使用 React 19 与 Ant Design 6,删除 MUI / Emotion 依赖。`App.tsx` 通过 `ConfigProvider` 选择默认浅色或深色算法并提供中文语言包,组件外观来自 Ant Design。`web/src/react/components.tsx` 将现有页面接口映射到 Input、Select、Switch、Checkbox、Tabs、Modal、Collapse、Card、Table、Alert、Tag 与 Typography,避免组件库替换影响领域逻辑;应用 CSS 仅管理布局。接口、SQLite schema、主题偏好键及秘密省略规则不变,无数据迁移。当前验证见 [React 迁移](react-migration.md);下面 MUI / HeroUI 记录为历史,不代表当前版本验收。
2026-10-04 前端实现已迁移为 React,最新界面采用原生 MUI 9 / Emotion,先前 HeroUI 已移除。当前导航、会话和 `ThemeProvider` / `CssBaseline` 组合根是 `web/src/react/App.tsx`,请求边界是 `web/src/react/api.ts`,业务视图位于 `web/src/react`,纯领域规则仍位于 `web/src/domain`。`pages/ui.tsx` 保留应用级 Field / Pick / Dialog / Table 接口,内部直接使用 MUI TextField、MenuItem、Dialog 和 Table 系列;不模拟另一框架的 compound API。主题仅选择 palette.mode,布局 CSS 不承担组件外观。此次框架替换无 API、SQLite schema 或真实 data 迁移,也不移动 Go 模块边界。下文 Vue composable 路径属于历史记录,已经删除;当前模块映射与交互变化见 [React 迁移](react-migration.md)。
当前前端生产构建及新嵌入 `bin/dev-build/mui/linkport.exe` 的 13 项迁移/domain Playwright 完整复测通过,三个 Node domain 文件共 16 项通过;首次 MUI DialogTitle 标题 id 重复问题已修正后重新构建。此轮没有改动或重复测试后端包,历史已知失败和真实协议验证边界保留,详见 [测试记录](testing.md)。
2026-09-27 的重构覆盖启动、领域模型、API、运行网关、协议、出口、集群、存储、后台任务和前端,并逐项审查工具与基础设施边界。采用 Go 函数、消费者侧接口和 Vue composable 表达设计模式,没有增加依赖或通用框架。下表区分实际拆分与已经具备清晰边界而保留的模块;不要求每个文件都发生改动。
| 模块 | 模式 | 解决的问题 | 实现入口 |
+13 -2
View File
@@ -1,10 +1,10 @@
# 项目标识与启动展示
linkport 的标识由蓝色圆角底、白色字母 P 和两端青色连接点组成:P 表示 port / portal,左右接入与出口表示服务通过网关连接。源图为 `web/public/linkport.svg`,保持简洁轮廓以适应浏览器标签和侧栏的小尺寸。图标使用固定品牌配色,在浅色和深色主题保持一致。
2026-10-05 按用户要求更新项目 Logo:使用蓝色渐变圆角底与倾斜的双端连接符,青色表示接入端,白色表示出口,中间连线体现 LinkPort 的网关连接。较旧的 P 字母与离散圆点图标轮廓更连贯,采用粗线、圆角和简单负空间,适配 16px favicon 和 30px 侧栏;浅深主题使用相同品牌资产。源图为 `web/public/linkport.svg`,为可缩放 SVG,不增加位图、字体或外部服务依赖。浏览器 theme-color 同步使用 #1677ff,与 Ant Design 默认主色呼应。
登录页、工作区左上角和浏览器 favicon 共用这份 SVG,随 Vite 构建复制到 `web/dist` 并嵌入 Go 单文件程序,无外部字体、图片服务或额外部署文件。页面图标旁已有 linkport 名称,因此内联图片使用空 alt,避免辅助阅读器重复朗读;README 图标保留替代文字。此次不修改 Windows EXE 的原生图标资源。
正常服务启动时在日志流打印一次对应 P 形端口的 ASCII 标识,随后打印现有版本、提交、构建时间和监听信息。终端标识使用纯 ASCII,不依赖 ANSI 色彩或特殊字体,支持 Windows、Linux、macOS 和重定向日志。标识出现在初始化阶段,不代表服务已成功监听;以随后监听日志和健康检查为准。
正常服务启动时在日志流打印一次历史 P 形端口的 ASCII 标识,随后打印现有版本、提交、构建时间和监听信息。终端标识使用纯 ASCII,不依赖 ANSI 色彩或特殊字体,支持 Windows、Linux、macOS 和重定向日志。标识出现在初始化阶段,不代表服务已成功监听;以随后监听日志和健康检查为准。
`version` / `--version`(含 JSON)、`data-path`、数据库检查与恢复、证书导出及维护命令不打印图案,保持脚本消费的输出内容。无配置或数据库迁移,现有启动参数和主题偏好不变。
@@ -13,3 +13,14 @@ linkport 的标识由蓝色圆角底、白色字母 P 和两端青色连接点
Node 22.22.3 前端生产构建通过(仅既有 chunk 大小提示),Go 1.25.0 全量测试及 `go vet ./...` 通过。命令边界测试使用临时无效数据路径在初始化阶段结束,验证正常启动标识出现一次、维护命令没有标识且 stdout 不被污染;原 JSON 版本及路径查询测试也通过。
构建 `bin/dev-build/branding-20260925/linkport.exe` 后完成隔离启动、嵌入页面、登录冒烟;全部 124 项 Playwright 通过,包含 SVG HTTP 类型/加载、favicon 引用、主题和手机布局检查,日志实际出现 ASCII 标识及随后版本/监听信息。使用临时数据库及本机 fixture,未验证真实上游服务,也未改动真实 data。图标 PNG 及界面预览保存在 `bin/previews/branding-20260925/`,源文件仍为可缩放 SVG。
## 项目 Logo 更新(2026-10-05)
原因:用户要求更换更好看的项目 Logo。原图以 P 字母与离散圆点为主,新图改为连贯的双端连接符,强化连接/网关含义。
结果与范围:web/public/linkport.svg 使用蓝色渐变圆角底、青白双端轮廓及倾斜中间连线,保留 64×64 viewBox 和可访问 title,粗线负空间兼顾 favicon 与侧栏的小尺寸。登录页、侧栏、README 和 favicon 继续共用同一 SVG 路径;浏览器 theme-color 更新为 #1677ff。图形为矢量资源,没有新增位图运行时依赖、外部字体或图片服务;默认 Ant Design 控件主题不变。
兼容:不修改接口、数据库、偏好键或部署流程,无数据迁移。启动日志的历史 ASCII 标识及 Windows 原生图标保持既有实现。未部署、重启真实服务或修改真实 data;保留本会话查询区集中改动。
实际验证:TypeScript / Vite 生产构建与 Go 1.27.1 新嵌入 bin/dev-build/logo-refresh/linkport.exe 成功,恢复 web/dist/.gitkeep;使用 LINKPORT_E2E_BINARY、临时 SQLite 和 18541/18542 隔离端口执行 React 迁移/domain Playwright,13/13 通过(15.2 秒)。18551 隔离验收确认嵌入 SVG HTTP 类型、图形内容、侧栏图片解码和 favicon 引用,输出浅深界面截图、16/30/64/96px 图标预览及 192px 透明 PNG 预览;视觉复核通过,文件在 bin/dev-build/logo-refresh。git diff --check 通过。只验证本机管理界面,没有连接真实协议目标。构建仍带 Node 22.10.0 版本及约 1.22 MB chunk 提示。此次纯资源更新未重复后端测试及旧 Vue 全套;本会话前一步全量失败记录仍保留,不宣称全套通过。
+13 -1
View File
@@ -1,6 +1,10 @@
# 本地输出目录
程序输出在 `bin/`;构建缓存使用 `bin/build-cache`,开发候选程序使用 `bin/dev-build`,Windows 单 EXE 发布目录使用 `bin/releases`。开发升级脚本的完整数据备份放在 `bin/backups`。
程序输出在 `bin/`;构建缓存使用 `bin/build-cache`,开发候选程序使用 `bin/dev-build`,Windows 单 EXE 发布目录使用 `bin/releases`。开发启动脚本的非日志数据备份放在 `bin/backups`。
从 2026-10-06 起,`start-dev.cmd` 的启动备份改为排除日志:数据目录根层的 `audit/`、`logs/` 不遍历、不复制,根层 `*.log`、`*.log.*` 文件也跳过。数据库及其 WAL/SHM/journal、主密钥、DPAPI 密钥、TLS 和其他非日志文件继续备份;显式选定的数据库及其 SQLite 侧文件即使名称匹配日志规则也保留。备份仍位于 `bin/backups/<时间戳>/<数据目录名>/`,源码与发行包入口一致。原日志、审计和旧备份不删除,数据库格式与停服/锁检查不变;这些新启动备份不包含外部审计历史,也不再是完整目录归档。
实际验证:`powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/start-dev.test.ps1` 通过。临时数据和编译 fixture 验证源码/发行包均排除审计数据库、审计 WAL、运行日志、轮转日志及 logs 目录,保留配置数据库及侧文件、密钥、TLS 和其他配置,且源日志内容不变。未启动真实服务或修改真实数据;本次仅改启动脚本,未重跑 Go/前端/Playwright。
根目录 `.tmp/`、`web/.tmp/` 和 `.tmp-test/` 是本地临时测试输出,包含 Go、Node、npm 缓存、浏览器测试结果和测试二进制,由 Git 忽略;已有文件保留,不随源码提交。
@@ -55,3 +59,11 @@ $env:LINKPORT_NODE = 'tools/node/node.exe'
`-BuildOnly` 只构建候选程序,不启动、不停止或替换现有服务,也不迁移真实数据库。构建脚本固定 `GOTOOLCHAIN=go1.27.1`;本次不自动下载、安装或打包编译器,已有 Go 的工具链获取仍由 Go 自身及网络配置决定。工具缺失时在服务停机和处理数据之前失败,并提示放入项目工具目录或配置 PATH。安装工具或修改用户 PATH 后需重新打开终端。
验证记录见 [测试记录](testing.md)。
## Windows startup process checks
If startup pauses at `Stopping previous linkport service...`, that stage is process discovery/stopping, not Go compilation. The launcher now uses `Get-Process` directly instead of querying `Win32_Process` through CIM/WMI, removing the potentially stalled CIM dependency. Only processes whose executable paths exactly match this checkout's `linkport.exe` or `bin/linkport.exe` are stopped; copies in other directories are left running. An unreadable executable path fails startup rather than guessing ownership. A process that exits during inspection is ignored, and each owned process retains the 10-second exit wait.
The launcher reports each owned PID, completion of the process check, and the start of the data backup, so a slow backup is distinguishable from process discovery. Source and extracted-release launchers share this behavior. Build/cache options, selected database path, complete-directory backup, data locks, and installation order are unchanged; no database migration is introduced. If an already-running launcher is stuck in the old CIM query, cancel that invocation with Ctrl+C and rerun `start-dev.cmd` to load the updated script.
Validation on 2026-10-06: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/start-dev.test.ps1` passed using temporary directories and compiled fixture executables. It checks both owned executable locations, preservation of a running unowned copy, no-process startup, build-only behavior, source/release startup, complete selected-directory backups, and existing failure protections. No real LinkPort service, database, protocol server, or migration was used; backend/frontend suites were not rerun for this script-only change.
+304
View File
@@ -1,5 +1,127 @@
# 变更原因与设计记录
## 网关 PUT 非法字段定位与账号结构修复(2026-10-10)
原因:用户报告 PUT /api/v1/proxies/:id 返回 invalid configuration fields。代码确认可复现路径为编辑器新增账号时混用了目标与网关账号字段,前端只筛选顶层字段,未筛选账号对象;另外小数最大连接数能通过原有范围校验,却无法解码成后端整数。
结果与范围:分别构造两类新增账号,并在目标/网关 payload 中保留对应 API 的账号字段集合;最大连接数要求整数。模型字段校验增加可携带路径的错误及数组元素路径;网关创建、更新和草稿测试将这些错误转换为包含具体字段的提示,类型错误也说明目标类型,不返回输入值。严格字段边界、密码留空保留、账号 ID 与映射保持不变。
兼容与迁移:不接受旧格式或额外字段,不变更存储结构和迁移,不写用户提供的真实网关 ID。仅在临时数据库验证保存和拒绝行为。全量 Go 测试除既有 internal/cluster MySQL fixture nil panic 外其余包通过;go vet ./... 通过,前端构建与 11 项 connection-draft Node 测试通过。新嵌入程序的浏览器最终结果见 testing.md;协议测试使用 fixtures,不代表真实数据库服务器验收。
最终浏览器验证:39 项专项通过(42.3 秒),包括目标新增账号和网关添加账号的真实隔离 PUT 回归及密码保留;git diff --check 通过。
## 后续 UI 排查与编辑页连接测试恢复(2026-10-07)
原因:用户继续反馈 UI 问题,并指出只有详情页测试好用。检查发现目标编辑页未接入已有草稿测试 API,强制保存后测试且仅展示 JSON;网关测试可能静默改用其他账号。此外,WebDAV 刷新/切页会丢失草稿,状态刷新错误会掩盖原始同步错误,已保存连接的协议选择看似可用却无响应。
结果:目标测试采用当前草稿和所选账号,编辑时携带 targetId 复用省略的凭据;SSH 明确确认指纹后重试,测试不保存。结果复用详情诊断并滚动定位;网关不替换明确选择的账号。已保存协议下拉明确禁用并说明原因。WebDAV 增加草稿离开/刷新确认、请求中禁用相关控件,区分同步失败和同步完成后的状态读取失败,修正密码保留说明。
范围与兼容:前端组件和测试接入;后端接口、数据结构和迁移保持不变。保留短确认框用于指纹与放弃草稿确认。未部署或修改真实数据。新增浏览器 HTTP fixtures 与现有 Go 草稿测试验证,不代表真实数据库、SSH 或 WebDAV 服务连通性验收;实际测试结果见 testing.md。
实际验证:新嵌入构建通过;最终 37 项当前 Playwright 专项通过(39.6 秒),现有 Go API `TestTargetDraft*` 通过。中间命名匹配误带入的两项旧 Vue 编辑器测试失败,未删除或跳过旧测试发现配置;详细记录见 testing.md。
## 全站缺项与保存失败反馈整改(2026-10-07)
原因:用户反馈代理保存失败不说明原因、不显示缺失字段,并要求检查整个项目的同类问题。此前多个页面把未填写值表现为不可点击按钮,目标账号问题也没有准确定位到字段。
结果:新增共享表单校验反馈,必填标记、字段红色错误、错误汇总和自动定位统一应用于代理/资产、目标/网关、工具/配置、同步、运维、目录及关联入口。保存失败保留当前输入,错误在当前页面可见;编辑字段只清理对应的校验问题。目标/网关校验按账号 ID 定位页签与输入,不把未知服务端错误错误标记为名称问题。高级 JSON 校验说明格式要求,Webhook URL 恢复明文 URL 输入;保留已有密码时不要求重复填写。
范围与兼容:前端交互整改,相关规则复用 domain 校验并与后端数值范围一致;SSH 指纹仍自动探测、明确确认后保存。API、数据库结构及迁移保持现状,不新增旧格式适配,不操作真实数据库,不启动真实目标连接。
实际验证:前端生产构建及 Go 1.27.1 嵌入构建通过;新程序上的 31 项 Playwright 专项全部通过(32.6 秒),使用临时 SQLite、18761/18762 端口及 HTTP fixtures。补充 Node 检查 24 项通过,persistent-target.test.mjs 因缺少 @vue/compiler-sfc、tcp-kube-forward.test.mjs 因引用已删除 KubernetesFields.vue 而加载失败,未删除或跳过这些旧测试。纯前端变更未重复 Go 全量测试;会话先前记录的 cluster fixture 失败仍未解决。本轮 git diff --check 通过,web/dist/.gitkeep 保留。
## SSH 保存时自动探测并展示指纹(2026-10-07)
原因:SSH 保存返回英文指纹格式错误,要求用户预先知道指纹;用户明确要求程序展示实际指纹后确认保存。结果:保存新 SSH 代理自动调用仅含 host/port 的既有探测接口,展示地址和完整 SHA256 指纹,用户点“确认指纹并保存”完成提交。探测失败在当前页显示中文错误。新旧指纹不同则展示变化及两个指纹,需明确确认新指纹。
移除普通指纹手填字段,不自动绕过主机校验;确认携带当前探测 ID 和指纹。过期或地址变化重新探测且不立即保存,凭据编辑保留探测;已有严格校验且身份不变的编辑保持直接保存。仅切换默认 SOCKS5/SSH 端口时调整为 1080/22,自定义端口保留。
范围:代理 React 编辑流程、字段和浏览器回归;复用现有后端安全绑定,没有 API/schema/数据库迁移变化,未部署或修改真实配置。测试使用 HTTP fixture 检查自动探测不携带凭据、确认前不保存、指纹显示、地址变化/过期重新确认以及最终严格校验 payload;实际构建与浏览器结果见测试记录,不代表真实 SSH 服务器验收。
## 工作区业务页面和表单细节统一(2026-10-07)
原因:代理保存错误显示在弹框背后的页面,用户要求业务弹框改为新页面,并将认证方式等有限枚举改为选择控件。结果:新增工作区 DetailPage 承载业务编辑/详情/结果/预览,统一标题、返回、焦点、滚动恢复及底部操作;隐藏原列表但保留其状态。App 协调菜单和标签切换,未保存内容允许取消离开;编辑器敏感字段与现有保存规则保留。删除/断开关联和放弃草稿保留短确认。
代理认证改为有效值下拉,SSH 默认用户名密码,SOCKS5 无认证时隐藏凭据;Kubeconfig 资产改为名称下拉。代理/资产错误与保存按钮同页显示,关闭/新建清理旧提示,错误文本去掉无意义的 Error 前缀;缺失凭据本地提示中文,已有密码留空继续保留。配置导入预览/差异页也显示相关操作错误。
范围:React 工作区与业务页面、通用布局、当前浏览器用例;无数据库/schema/API 变化,没有迁移、部署或修改真实数据。自动审批曾拒绝把删除确认一并页面化,已采用保留短确认的安全替代方案完成其余转换;没有绕过或遗留该被拒绝操作。首轮 21 项当前浏览器用例通过,最终复测见测试记录;旧 Vue 测试删除仍属先前独立的待确认范围,不变更发现规则。
## 未知目标会话错误不再归类为协议异常(2026-10-06)
原因:用户提供的目标测试复现显示 SOCKS5 各阶段及目标 TCP 成功,但 MySQL protocol_session 以 kind=unknown 失败;ObservedFailureCategory 将所有非认证/权限会话错误强制映射 target_protocol,前端因此断言协议响应无效或 TLS 问题,超出了证据范围。
结果:增加 target_session 分类及中英日文提示,只表示会话建立阶段失败、具体原因未确定;明确 protocol/authentication/permission 仍保持对应类别,前置阶段成功结果不受影响。范围为 gateway 错误分类、前端说明及既有回归;无数据库、配置格式或迁移变化,没有更改用户目标、TLS、凭据或替换运行程序。
验证:gateway/API 专项通过,新增 unknown/network/空类型分类断言,保留明确协议/认证/权限断言;React 浏览器用例使用 HTTP 422 fixture 验证新分类可见且不再显示“目标协议响应异常”。最终构建及全量检查见测试记录。运行服务的一次真实目标测试仅用于定位观测阶段,MySQL 实际失败原因仍需进一步证据,未宣称连通性修复。
## 代理测试成功与失败的颜色标识(2026-10-06)
原因:测试结果主要显示 JSON,成功和失败不够直观。结果:使用 Ant Design 默认 Alert,在独立测试弹窗显示绿色成功、红色失败、蓝色进行中,并提供图标和状态文字;HTTP 失败也进入同一弹窗展示原因。重新测试清除旧状态,仅明确 valid/authenticated 成功才显示绿色,保留范围说明和结果详情。影响范围继续使用自己的弹窗。
范围:React 代理列表和既有浏览器回归;没有 API、协议、数据库格式或迁移变化,未更新实际运行服务。验证:TypeScript/Vite 与重新嵌入的 Go 程序构建通过;临时 SQLite、隔离 18761/18762 的 outbound-fields/current-pages 共 4 项通过(13.7 秒),包含四种代理成功绿色提示、随后 422 失败红色提示及旧成功状态消失。状态响应使用 HTTP fixture,没有真实上游连接验证。后端代码未变,沿用上一轮 Go 检查及已知集群测试失败;日志在 bin/dev-build/cleanup/test-colors-{build,browser}.log。
## 增加 SOCKS5 / Kubernetes 代理测试(2026-10-06)
原因:用户明确要求 SOCKS5/Kubernetes 也支持直接测试,覆盖上一轮仅限制前端入口的处理。结果:同一 POST /outbounds/:id/test 新增 SOCKS5 握手/认证和 Kubernetes API/RBAC 权限测试,前端四种代理均提供对应操作,执行中禁用测试按钮。SOCKS5 与实际拨号复用同一认证协商代码,10 秒超时并响应取消;Kubernetes 复用既有权限检查,解析保存的凭据资产,8 秒超时,不执行登录插件。权限拒绝或未知返回 422,错误不带原始上游文本或秘密。
范围与兼容:outbound 协议协商/权限检查、API 域处理、React 代理列表及测试。接口扩展,无数据库/schema/配置格式变化,无迁移或真实数据修改;SSH/sing-box 原测试行为保留。SOCKS5 不发送目标 CONNECT,Kubernetes 不检查资源存在、Pod 就绪或实际转发;结果说明测试边界,完整链路继续由目标连接测试覆盖。
验证:SOCKS5 TCP fixture 覆盖无认证、密码认证、拒绝、错误协议及取消后关闭连接;API 测试覆盖 CSRF、参数校验、Kubeconfig 资产解析、HTTPS fixture 上权限允许/拒绝/不可用及敏感响应不泄露。专项通过,最终全量 Go/vet、前端构建和隔离浏览器结果见测试记录。没有真实上游验证,没有部署运行服务。
## 代理列表测试操作与后端能力一致(2026-10-06)
原因:用户在 SOCKS5 代理点击测试后收到“connection test is supported for SSH proxies”;前端未区分类型,向所有代理暴露同一个 `/outbounds/:id/test` 操作。结果:SSH 显示“SSH 连接测试”,sing-box 显示“校验配置”,SOCKS5/Kubernetes 提示在关联目标服务中测试连接。保留现有后端测试能力,避免将配置校验宣称为连通性验证。
范围:AssetsPage 列表操作与 outbound-fields 浏览器回归;无 API、数据库或配置格式变更,不需迁移,未替换运行服务。验证:TypeScript/Vite 与新嵌入 Go 可执行文件构建通过;隔离端口 18761/18762、临时 SQLite 的 outbound-fields/current-pages 共 4 项通过(12.8 秒)。类型分支使用 HTTP fixture,保存操作使用真实本机 API;未连接真实 SOCKS5/SSH/Kubernetes 上游。日志位于 bin/dev-build/cleanup/outbound-action-{build,browser}.log。
## 保留数据库迁移,继续清理前端旧适配(2026-10-06)
原因:用户将范围明确为“清理全部前端旧适配、旧页面和旧测试;保留数据库迁移”,覆盖此前删除后端旧格式支持的决定。恢复旧代理拆分、目标账号/备注、作用域、共享代理、审计拆分、SSH 身份及外置主密钥/TLS 身份迁移,保留 migrate-data/migrate-key。允许旧 schema 0–3 启动升级,未来版本拒绝打开;迁移成功后再写 schema 3。网关顶层凭据及权限转换为账号数组,内联目标代理和 TCP Kubernetes 转共享引用,重复打开不重复生成代理。S3 设置建表、读写和旧设置密钥校验保持删除;外部版本 1 配置包仍不接受,数据库迁移与配置包导入不同。
前端继续使用原生 React/Ant Design,移除 ocean 主题专用映射,旧值按未知主题回退 system;CSRF 测试迁移到当前选择器,保留令牌过期刷新且只创建一次的断言。数据库迁移测试通过直接构造旧密文行,避免经当前保存 API 假装产生旧数据。
影响范围:store 启动迁移、桌面旧密钥读取和 CLI 迁移入口、前端主题与测试。迁移验证仅操作临时数据库和隔离端口,没有部署、修改真实数据库或远端文件。部署迁移仍需停服并完整备份数据目录;开发启动脚本按用户要求跳过日志的备份不等同于完整迁移备份。
尚未完成:自动审批拒绝批量删除旧 Vue 测试和独立 Kubernetes/TCP 编辑分支,理由是可能移除现用功能及其覆盖。被拦截文件仍保留,测试发现规则未收窄。具体 53 个测试和影响见 [待确认清单](frontend-cleanup-pending.md)。
实际验证:Go 1.27.1 的 store/desktop/CLI 专项通过;全量 go test 的其余包通过,internal/cluster 的 TestMySQLReplicationAndGroupDiscovery 仍在 go-mysql WritePublicKeyAuthPacket 中出现既有空指针异常;go vet ./... 通过。TypeScript/Vite 构建和重新嵌入 Go 程序通过,web/dist/.gitkeep 保留。当前 React 专项包含 CSRF,共 19 项通过(主题映射删除后的最终复测见测试记录)。完整旧浏览器套件此前在旧 account-editor 选择器失败,本轮未将专项结果视为全套通过。协议验证使用 fixture,没有连接真实上游服务。
## 连接详情不再把请求错误误报为 unknown(2026-10-04)
原因:详情测试捕获管理接口错误后只保存 stage/diagnostics;响应未带 stage 时,界面丢失 HTTP 状态并套用 unknown 阶段提示。结果:未带阶段的失败显示“未收到连接阶段诊断”,区分 HTTP 错误与未取得有效响应,并显示受限的 API 路径;保留服务器明确返回的阶段说明,不推断目标网络或密码结论。只显示状态与路由元数据,不展示任意错误正文、查询参数或凭据。范围为 React 连接详情,无 API/schema/数据迁移。
验证:前端构建通过,使用 Go 1.27.1 重新嵌入 bin/dev-build/mui/linkport.exe,临时 SQLite 与端口 18241/18242 的迁移/domain Playwright 13 项通过(11.6 秒)。新增断言用 HTTP 400 无阶段、422 有阶段响应 fixture,验证提示分流及敏感正文不显示;未连接真实远端协议服务,未修改后端。
## React 原生 MUI 默认主题(2026-10-04)
原因:用户在 React / HeroUI 迁移和布局调整后,进一步要求“使用 MUI”。结果:登录、导航、主题选择、查询、连接编辑器、资源/画布、审计、资产、运维、备份、WebDAV 与解密全部直接使用原生 MUI 9 / Emotion;移除 HeroUI 依赖及组件调用。`ThemeProvider`、`CssBaseline` 与仅设置 `palette.mode` 的 `createTheme` 提供默认浅深外观,标题使用 Typography,卡片使用 Card / CardContent,表格和对话框使用 MUI 原生组件系列。不自定义 palette、字体、阴影、圆角或控件状态样式,也不建立 HeroUI API 兼容壳;前轮留白、集中查询与窄屏排列仅作为布局保留。
范围与兼容:仅前端依赖、组件、浏览器选择器和相关文档。没有改动 Go、HTTP API、SQLite schema、备份格式或真实 data,无 API/schema/data 迁移,也没有部署真实数据目录。保留主题/范围存储键、ocean 转浅色、CSRF 边界、空秘密省略、引用保护、SSH 指纹确认、未保存确认、迟到响应失效和配置 import expectedRevision。MUI Select 的验收改用 combobox / option,其他旧 Vue 用例的迁移工作与已知后端失败仍按历史记录保留。详见 [当前前端](react-migration.md) 与 [界面设计](ui-design.md)。
实际验证:辅助页面与 Operations 的独立严格 TypeScript 检查及生产构建通过;构建结束后使用 Go 1.27.1 重新嵌入为 `bin/dev-build/mui/linkport.exe`(2026-10-04 17:40)。使用隔离端口 18241/18242、临时 SQLite 完整运行 `react-migration.spec.ts` / `ui-domain.spec.ts`,13 项通过(11.5 秒);三个 Node 草稿/账号映射/访问模式 domain 文件共 16 项通过,`git diff --check` 通过。首次运行 10 项通过、3 项因 MUI DialogTitle 自动 id 与自定义标题关联重复、可访问名称包含取消按钮文字失败;修正标题关联后重新构建并完整复测,不只重跑失败项。浏览器用例使用 MUI combobox / option,并覆盖资产 PUT 省略 content、秘密保留及关闭后不意外弹影响对话框。额外检查 1440、1024、390、320px 均无横向溢出和 pageerror;亮暗、移动及编辑器截图位于 `bin/dev-build/mui`。详细记录见 [测试记录](testing.md)。本轮未重复未修改的后端包测试,前轮全量后端及旧 Vue suite 的已知失败仍保留;没有真实协议或 WebDAV 远端服务器验证。
## 工作区增加留白(2026-10-04)
原因:用户认为集中查询后的样式不够有呼吸感。结果:增加内容区边距、侧栏菜单间距、标题/说明层次、卡片内部及内容块之间留白,资源内容最大宽度限制为1440px;六个查询条件改为宽屏3列、平板2列、手机1列。资源页标题采用HeroUI/Tailwind默认排版工具类,查询Card采用框架间距类,未添加自定义背景、颜色、圆角或阴影。影响工作区布局与表单间距,字段、筛选状态与保存行为不变,无API或数据库迁移。详细数值见[工作区交互](workspace-ui.md)。
实际验证:生产构建通过,Go1.27.1重新嵌入为`bin/dev-build/airy-layout/linkport.exe`,恢复dist/.gitkeep;使用临时SQLite/18231、18232运行迁移/domain Playwright,13项通过(12.1秒)。18233独立浏览器检查1440/1024/390/320px网格列数和无横向溢出,浅深与移动截图已生成。本次仅前端布局,未重复后端全量测试;同次迁移的已知后端与旧浏览器suite失败继续保留,不称全套通过。未部署、修改真实data或连接真实协议服务器。
## 查询条件集中展示(2026-10-04)
原因:用户截图中的项目/环境在页面标题上方,搜索/协议/状态/分组在标题下方,要求查询条件放一起。结果:网关、目标与概览共用的资源页面,在标题下使用一个 HeroUI 默认 Card 集中六个条件,相关范围操作按钮放在同卡片底部。App 将范围字段和操作作为 React 内容传给资源页,保持共享状态及现有筛选逻辑,避免复制状态。仅调整布局;其他页面范围入口、字段值、筛选持久化和后端契约不变,无数据迁移。说明见 [工作区交互](workspace-ui.md)。
实际验证:`npm.cmd --prefix web run build` 通过,Go 1.27.1 构建新嵌入 `bin/dev-build/query-group/linkport.exe`,恢复 dist/.gitkeep;隔离数据库/18221、18222 的 React 迁移及 UI domain Playwright 13 项通过(12.0 秒)。独立 18223 浏览器检查六个条件同属查询区域、上方范围条不重复、清除项目有效、390px 无横向溢出,桌面/移动截图已生成。没有写新增镜像实现测试,没有重复运行未修改的后端包;同轮迁移的 Go 全量已知失败与 vet 结果见下一节,未称全套通过。无真实远端协议服务器测试,未修改真实 data。
## React 与 HeroUI 默认样式(2026-10-04)
原因:用户要求迁移 React、直接使用 HeroUI,并明确删除旧样式。结果:入口、登录、工作区、连接编辑、资源列表/画布、审计、代理/文件资产、运维、配置、WebDAV 与解密全部改为 React / HeroUI 3;删除 Vue / Element Plus 依赖及原组件、composables 和四份旧 CSS。没有嵌套旧 Vue 应用。新 CSS 仅负责布局,外观由框架默认 Card、Table、Alert、Select、Checkbox、Switch、Tabs、Modal 等组件提供。
范围与兼容:前端及协作说明、文档、浏览器验收,未修改 Go、API 或 SQLite schema,也未部署或修改真实 data。保留 CSRF 的明确拒绝重试、空秘密字段省略、引用删除保护、主机指纹确认、未保存确认、密码限时与过期结果保护。海蓝自定义主题删除,原 ocean 存储迁移为浅色;项目/环境及画布布局沿用存储键,React 列设置与最近访问使用新键。复杂代理参数提供 JSON 高级编辑,审计 MySQL 结果使用可读 NDJSON 和下载,旧资产下拉内新建入口由独立资产页代替。具体职责及交互变化见 [React 迁移](react-migration.md)。
实际验证:`npm.cmd --prefix web run build`(tsc + Vite)通过;构建后使用 Go 1.27.1 重新嵌入生成 `bin/dev-build/react-final/linkport.exe`,恢复 `web/dist/.gitkeep`。使用该程序与独立 18191/18192 端口、临时 SQLite 的 React 迁移及 UI domain 共 13 项 Playwright 通过;另 16 项草稿/账号映射/访问模式 Node domain 回归通过。真实 UI/API 覆盖目标和网关保存、空密码更新保留秘密、登录退出、浅深/系统主题与旧 ocean 兼容、未保存确认、资产/审计/配置页,以及 320/390px 无横向溢出。额外隔离 API 验证资产内容及 SOCKS5 密码省略保留、Token 创建撤销、WebDAV strict 字段/clearPassword、备份预览/导入/版本409保护、历史/diff/restore、告警及 AES fixture 解密。资源 Table 的手动选择还使用模拟 API 验证。截图在 `bin/dev-build/react-final`,包含桌面、编辑器和浅深移动界面。
验收限制:保留了旧浏览器用例,未通过删除或忽略旧 suite 声称全套通过。历史用例仍依赖已删除的 `.target-card`、`.el-*`、旧“配置”/“+ 新建目标”文案与全宽 Vue 页面,需要继续迁移。全量发现 146 项;按 5 次失败上限运行,旧用例失败后停止,未跑完全部。`go vet ./...` 通过;`go test ./... -count=1 -timeout=120s` 仍复现此前已记录的 API WebDAV 口令断言、cluster MySQL RSA fixture panic、Windows desktop 路径断言,以及 syncjob 明文/加密旧断言失败。其余包(包括所有 protocol 包)通过。本轮没有真实 SSH/Kubernetes/数据库/WebDAV 远端服务器验收,不能将 fixture 或管理 API 测试表述为真实协议连接通过。Node 22.10 的 Vite 推荐版本提示仍存在,不影响此次生产构建结果。
## Kubernetes 本机高位和固定端口(2026-10-02)
原因:用户要求使用高位端口,并能在页面配置固定端口。结果:Kubeconfig 代理提供自动/固定两种模式,自动分配 49152–65535,绑定失败最多尝试 32 个端口;固定范围为 1024–65535,占用或绑定拒绝时明确报错,不自动改端口。仅监听 127.0.0.1。字段为 outbound.localPort,省略或 0 为自动,旧配置继续兼容,无迁移。目标数据库端口和网关对外端口不变。独立隧道无法共享同一固定端口,具体范围、持久连接及 SPDY 超时限制见 [Kubernetes 配置](kubernetes.md)。
@@ -1181,3 +1303,185 @@ live MySQL server or real data was used.
原因与结果:用户要求增加主题切换。现有四种主题下拉保留,登录页和工作区工具栏增加太阳/月亮一键切换按钮;跟随系统时按当前实际主题决定切换方向。继续复用原偏好存储键,刷新保留选择;仅改变主题状态和控件,无后端接口、数据或配置迁移。范围为 theme.ts 和 ThemePicker.vue,详见 UI 设计说明。
实际验证:前端生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 构建新的 artifacts/ansi-insert/linkport.exe 后以 LINKPORT_E2E_BINARY 运行 theme-switch.spec.ts、dbx-ui.spec.ts、mysql-access-modes.spec.ts,5/5 通过。覆盖全部菜单的四种主题、登录页与工作区偏好一致性、系统主题变化、一键切换与刷新保留,以及 1440/390/320px 按钮可见和无横向溢出,输出相应截图。使用隔离临时数据库和 28181/28182 端口,未替换真实服务。git diff --check 通过。全套回归和后端失败沿用上方实际记录,不因专项通过而宣称全套通过。
## React + Ant Design 组件库迁移(2026-10-04)
原因:用户明确要求前端采用 React + Ant Design,替换当前 React 版本的 MUI。
结果与范围:登录、导航、资源列表、编辑器、画布、审计、资产与辅助页面统一由 Ant Design 6 原生组件渲染。应用级接口集中在 components.tsx,表格转换为 Ant Design columns/dataSource;删除 MUI、MUI icons、Emotion React/styled 直接依赖,图标改为 Ant Design icons,中文 ConfigProvider 提供默认浅色/深色算法与 reset。布局 CSS 改用 Ant Design 类名。关闭按钮的中文自动间距以保留现有可访问名称;取消动作移入编辑器底部,弹窗标题只包含资源标题;Select 关闭虚拟滚动,使中文选项可直接用于浏览器与辅助技术交互。侧栏标签布局移除默认横向外边距,兼容窄屏。
兼容与迁移:仅前端组件、主题来源与 DOM 变化,不修改 HTTP API、Go 协议、SQLite schema、偏好存储键或留空凭据提交规则,无数据迁移。工作区已有 React 迁移改动全部保留;协作说明同步更新为 Ant Design。详见 [React 迁移](react-migration.md)。
实际验证:前端 TypeScript / Vite 生产构建通过;Go 1.27.1 重新嵌入 `bin/dev-build/antd/linkport.exe` 后,使用临时 SQLite 与隔离 18391/18392 端口运行 react-migration.spec.ts、ui-domain.spec.ts,13/13 通过(14.6 秒)。三个 Node domain 文件 16/16 通过;`go vet ./...` 通过。最终新程序完成 11 个菜单 × 浅深两种主题 × 1440/1024/390/320px 共 88 组检查,无 pageerror 或页面横向溢出,16 张截图在 `bin/dev-build/antd`。管理请求使用真实本机 API,阶段错误使用 HTTP 响应 fixture;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。恢复 `web/dist/.gitkeep`,未部署或修改真实 data。
`go test ./... -count=1 -timeout=120s` 未通过:API 的 TestWebDAVSyncSettingsAndAuthorization(同步口令未保留)、cluster 的 TestMySQLReplicationAndGroupDiscovery(fixture nil panic)、desktop 的路径相关用例、syncjob 的两个明文秘密断言失败,与既有记录一致;协议包均通过,不能视为真实上游服务器验证。日志见 `bin/dev-build/antd/go-test.log`。构建仍有 Node 22.10.0 低于 Vite 推荐版本及约 1.20 MB JS chunk 提示,构建成功。
完整 Playwright 套件发现 146 项,使用 `--max-failures=5` 执行后 5 项失败、141 项未运行,另有停止后的 suite 级错误提示。失败来自 account-editor(2 项)、account-targets(1 项)、asset-editor-dialog(2 项),仍依赖旧 Vue 的 `.target-card`、`.el-select`、`.connection-editor` 及“配置”“+ 新建目标”等旧文案;未迁移的旧用例不记为通过。日志在 `bin/dev-build/antd/playwright-suite.log`。最终 13 项当前 React 专项通过与完整旧套件结果分别记录,`git diff --check` 通过。
## 界面密度与小屏编辑优化(2026-10-04)
原因:Ant Design 迁移后,查询卡片同时叠加应用 padding 与框架 padding,列设置独占整张卡片,手机编辑器整体高度超过视口,保存与取消需要长距离滚动;导航符号及主题标签也占据重复空间。
结果:查询区删除重复 padding,资源关键词前置,统一收紧字段、页面与标题间距;标题说明使用 Ant Design secondary 文本。列设置改为列表工具栏的 Popover,增加当前筛选结果总数,仍复用原列偏好键。导航使用统一 Ant Design 图标与 Tooltip,保留中文可访问名称、资源计数与小屏图标栏。主题下拉隐藏视觉标签但保留关联 label 和“主题”可访问名称。无资源时提供创建入口,已有资源但筛选为空时提供“清除筛选”,同时清空资源关键词、协议、状态及工作区项目/环境,保留分组和列偏好。
编辑与确认弹窗限制在当前视口内:标题和底部动作固定在弹窗布局中,内容区独立滚动,窄屏底部账号选择独占一行,保存/测试/取消始终可达。使用 Ant Design 默认主题与控件外观,CSS 只调整布局、滚动和视觉隐藏标签。新导航与列设置视图分别位于 WorkspaceNavigation.tsx、resources/ResourceColumns.tsx;App.tsx 继续协调范围状态。
范围与兼容:影响工作区导航、主题工具栏、资源查询/列表及共享弹窗;清除筛选会更新既有非敏感筛选偏好,无新增存储格式、HTTP API 或 SQLite schema 变化,不需数据迁移。凭据省略、未保存确认和请求权限不变;未部署、重启真实服务或修改真实 data。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/ui-polish/linkport-final.exe,以 LINKPORT_E2E_BINARY、临时 SQLite 和 18451/18452 端口运行 React 迁移/domain Playwright,13/13 通过。额外隔离 18431 端口验收空资源创建、空搜索/范围结果清除及列偏好跨刷新保留;11 个菜单 × 浅深主题 × 1440/1024/390/320px 共 88 组检查无 pageerror 或页面横向溢出,8 组编辑器在滚动内容前后保存按钮均完全位于视口内,截图 16 张。浏览器使用本机真实管理 API 和隔离配置;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。日志与截图位于 bin/dev-build/ui-polish。
Go vet ./... 通过。全量 go test ./... -count=1 -timeout=120s 仍失败于 API 的同步口令保留、cluster 的 MySQL fixture nil panic、desktop 的数据路径用例与 syncjob 的两个明文秘密断言,与前轮记录一致;协议包通过但不代表真实服务器验证。首次 Go 与 Playwright 并发时,Go 目录扫描撞上 web/test-results 清理,报目录不存在;浏览器完成后重新执行 Go 检查,上述为重跑结果。
完整旧 Playwright 套件发现 146 项,使用 --max-failures=1 --timeout=15000 运行后,首项 account-editor 因依赖旧 .target-card / “配置目标”失败,145 项未运行,另有停止后的 suite 级错误提示;不能把专项通过视为旧套件全部通过。构建保留 Node 22.10.0 低于 Vite 推荐版本与约 1.22 MB JS chunk 提示。git diff --check 通过。
## 审计查询条件集中(2026-10-05)
原因:用户截图中的命令审计把项目/环境放在标题上方,审计字段放在标题下方,查询需要在两处操作。
结果与范围:命令审计和连接与审计取消独立的顶部范围栏,将项目、环境与网关、协议、目标、来源、类型、关键词、起止时间等页面适用条件放进标题下同一张查询卡片,使用与资源页一致的响应式字段网格。筛选、重置、导出 CSV、清除范围、管理项目与环境、刷新均集中到卡片内动作栏,筛选为主按钮。移除查询卡片内重复的审计标题,保留页面主标题。App.tsx 仍协调共享范围,AuxiliaryPage 仅传递视图,AuditPage 负责审计查询。
兼容:只调整审计页布局与按钮主次,不修改查询参数、范围变化时重置审计条件、CSV 导出、权限、偏好键或后端协议;无接口/数据库迁移,未部署或修改真实 data。其他辅助页的范围栏及资源页保持原布局。更新已有浏览器用例对事件页面主标题的定位。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/audit-query/linkport.exe,使用 LINKPORT_E2E_BINARY、临时 SQLite 和 18511/18512 端口运行 React 迁移/domain Playwright,13/13 通过(16.2 秒)。隔离 18521 端口另检查两种审计页 × 浅深主题 × 1440/1024/390/320px,共 16 组通过:每页仅一个查询区,项目/环境及全部适用字段和动作均在其中,无独立范围栏、页面横向溢出或 pageerror。真实本机 API 请求确认项目、环境和关键词一起提交。截图和日志位于 bin/dev-build/audit-query;没有连接真实数据库、SSH 或其他协议目标。git diff --check 通过。
补充全量检查:Go 1.27.1 下 go vet ./... 通过;go test ./... -count=1 -timeout=120s 未通过,失败为 API 同步口令保留、cluster MySQL fixture nil panic、desktop 路径用例、pgproxy/TestPreparedRejectsDifferentIdentity 以及 syncjob 两个明文秘密断言,完整细节在 bin/dev-build/audit-query/go-test.log。未将后端全量或协议 fixture 结果当作真实目标服务器验证。完整 Playwright 发现 146 项,使用 --max-failures=1 --timeout=15000 执行后,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示,不记为全套通过。构建保留 Node 22.10.0 版本和约 1.22 MB chunk 提示,仍成功。
## 项目 Logo 更新(2026-10-05)
原因:用户要求更换更好看的项目 Logo。原图以 P 字母与离散圆点为主,新图改为连贯的双端连接符,强化连接/网关含义。
结果与范围:web/public/linkport.svg 使用蓝色渐变圆角底、青白双端轮廓及倾斜中间连线,保留 64×64 viewBox 和可访问 title,粗线负空间兼顾 favicon 与侧栏的小尺寸。登录页、侧栏、README 和 favicon 继续共用同一 SVG 路径;浏览器 theme-color 更新为 #1677ff。图形为矢量资源,没有新增位图运行时依赖、外部字体或图片服务;默认 Ant Design 控件主题不变。
兼容:不修改接口、数据库、偏好键或部署流程,无数据迁移。启动日志的历史 ASCII 标识及 Windows 原生图标保持既有实现。未部署、重启真实服务或修改真实 data;保留本会话查询区集中改动。
实际验证:TypeScript / Vite 生产构建与 Go 1.27.1 新嵌入 bin/dev-build/logo-refresh/linkport.exe 成功,恢复 web/dist/.gitkeep;使用 LINKPORT_E2E_BINARY、临时 SQLite 和 18541/18542 隔离端口执行 React 迁移/domain Playwright,13/13 通过(15.2 秒)。18551 隔离验收确认嵌入 SVG HTTP 类型、图形内容、侧栏图片解码和 favicon 引用,输出浅深界面截图、16/30/64/96px 图标预览及 192px 透明 PNG 预览;视觉复核通过,文件在 bin/dev-build/logo-refresh。git diff --check 通过。只验证本机管理界面,没有连接真实协议目标。构建仍带 Node 22.10.0 版本及约 1.22 MB chunk 提示。此次纯资源更新未重复后端测试及旧 Vue 全套;本会话前一步全量失败记录仍保留,不宣称全套通过。
## 按钮统一使用底色与彩色文字(2026-10-05)
原因:用户截图中详情为描边、编辑为纯文字、启停为实心蓝色、删除为红色描边,要求全部使用背景底色并以不同颜色文字区分动作。
结果与范围:应用级 Button 统一映射为 Ant Design 原生 variant="filled",即浅底色及对应彩色文字,不添加 CSS 配色或自定义主题。保留旧页面 variant 接口用于默认色语义:原 contained 默认主色,其余默认中性色;显式 error/success/warning/info/secondary 分别映射 red/green/orange/cyan/purple。资源列表详情蓝色、编辑青色、启动绿色、停止橙色、删除红色;批量启停及画布编辑/启停使用相同语义。导航与列设置改为复用统一按钮,选中导航为主色底色、普通导航为中性底色,图标按钮也使用底色。禁用、大小、事件和表单提交行为沿用框架;fullWidth 显式映射到宽度,避免传给 DOM。
兼容:只改变应用操作按钮的外观和语义色,保持 Ant Design 默认浅深算法、原中文名称和请求/确认行为,无接口、数据库或偏好迁移。保留本会话查询条件合并和新 Logo;未部署或修改真实 data。系统内部的关闭、分页箭头等框架控件仍由 Ant Design 管理。
实际验证:TypeScript / Vite 生产构建与 Go 1.27.1 新嵌入 bin/dev-build/button-soft/linkport.exe 成功,恢复 web/dist/.gitkeep;LINKPORT_E2E_BINARY 指向该程序,使用临时 SQLite、18611/18612 端口执行 React 迁移/domain Playwright,13/13 通过(15.6 秒)。另以隔离 18621 端口和 GET 资源响应 fixture 检查浅深主题、1440/390/320px:详情/编辑/启动/停止/删除均使用 filled 和对应语义色,计算样式背景非透明、文字与背景不同,无 pageerror 或页面横向溢出;导出界面及动作区域截图,视觉复核通过。fixture 验收没有执行网关启停或连接真实目标。日志及截图在 bin/dev-build/button-soft,git diff --check 通过。构建仍有 Node 22.10.0 版本与约 1.22 MB chunk 提示,成功生成产物。纯前端外观更新不重复后端测试,沿用本会话 audit-query 全量后端失败与 vet 通过记录,不记为后端全量通过。
完整旧 Playwright 套件另发现 146 项,以新程序、18631/18632 端口及 --max-failures=1 --timeout=15000 执行,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示;不宣称全套通过,详见 bin/dev-build/button-soft/playwright-suite.log。
## 命令审计查询下拉恢复(2026-10-05)
原因:React 迁移把审计筛选统一渲染成 Field 文本框,丢失旧 Vue 的协议、网关、目标和处理状态选择器;用户要求恢复查询下拉。
结果与范围:增加原生 Ant Design QuerySelect(标签关联、中文可访问名称、按名称搜索、清空、全部选项、非虚拟选项),命令审计恢复网关、目标、协议及处理结果下拉。网关/目标显示名称、提交 ID,按当前项目/环境过滤并按名称及 ID 排序;处理结果使用 allowed/blocked/input 三种既有值,协议沿用实际服务类型值。操作类型仍支持任意协议命令输入,来源 IP、关键词与时间继续为原输入控件。项目/环境使用独立 AuditScopeFields 下拉,包含全部和未分配选项,并保留当前存储中的未列出范围值;范围状态仍由 App.tsx 协调。连接与审计同时复用项目/环境与事件服务下拉,事件服务覆盖同范围网关和目标。AuditFilters 负责独立筛选视图,AuditPage 继续处理查询/分页/详情。
兼容:所有条件仍在同一查询卡片中,保留上一步底色按钮与新 Logo;范围变化时仍重置审计条件,重置仅清除审计字段、保留工作区范围。HTTP 查询参数、CSV 导出、ID 值、权限、存储格式与数据库 schema 不变,无数据迁移,未部署或修改真实 data。网关/目标字段改为名称选择,不需手工输入 ID;其余辅助页和资源页的范围控件不变。
实际验证:TypeScript / Vite 生产构建及 Go 1.27.1 新嵌入 bin/dev-build/audit-selects/linkport.exe 成功,恢复 web/dist/.gitkeep。以 LINKPORT_E2E_BINARY、临时 SQLite、18711/18712 端口执行 React 迁移/domain Playwright,14/14 通过(16.7 秒);新增回归使用 GET 资源 fixture,验证资源名称下拉、跨项目选项排除、项目/环境/网关 ID/目标 ID/协议/处理结果/关键词实际查询参数,以及重置清除审计条件并保留范围。18721 隔离验收两种审计页 × 两种主题 × 四种屏宽,共 16 组通过,查询字段与动作保持集中,项目/环境选择后与关键词一起提交,无 pageerror 或页面横向溢出;输出 16 张截图。日志和截图在 bin/dev-build/audit-selects,没有连接真实数据库或其他协议目标;git diff --check 通过。构建仍带 Node 22.10.0 版本及约 1.22 MB chunk 提示,成功生成产物。纯前端恢复不重复后端测试,本会话 audit-query 后端全量失败与 vet 通过记录仍保留。
完整旧 Playwright 套件发现 147 项,使用新程序、临时 SQLite、18731/18732 隔离端口及 --max-failures=1 --timeout=15000 执行;首项 account-editor 仍依赖旧 Vue .target-card / 配置目标而超时,1 项失败、146 项未运行,另有停止后的 suite 级错误提示,不宣称全套通过。日志见 bin/dev-build/audit-selects/playwright-suite.log。
## 网关访问模式归属账号(2026-10-05)
原因:网关级只读覆盖账号显式模式,无法让同一网关中的不同账号分别拥有只读、读写或全放行权限。
结果:GatewayAccount.Apply 使用账号显式 accessMode;空值仍使用网关历史值作为兼容回退。编辑器仅在账号区提供账号访问模式,不再提供继承网关选项;读取旧账号时将空模式展示为原回退模式,新账号默认读写。MySQL 支持只读、读写、全放行,MongoDB 支持只读、读写;SSH/SFTP/TCP 不增加模式。无账号 MongoDB 保留免认证访问模式。列表按有效账号模式汇总,不同模式显示按账号设置。来源限制、认证、审计及目标数据库权限不变。
兼容影响:无需数据库迁移,不修改真实 data。旧空模式账号行为不变;原网关为只读但账号显式为读写或全放行的配置,现在按账号显式权限生效,重连后使用新策略;这是本次按账号授权的行为变更。已建立连接保留原会话策略。旧网关字段保留用于空模式和免认证兼容。
实际验证:前端生产构建通过,Go 1.27.1 重新嵌入 account-linkport.exe,恢复 web/dist/.gitkeep。账号模式模型/API专项通过;Node 12/12 通过;隔离 SQLite、18741/18742 端口的 React/domain Playwright 14/14 通过(18.5 秒),验证网关页无模式、账号页无继承选项及真实 API 保存只读。产物与日志在 bin/dev-build/audit-selects/account-*。使用本机管理 API 与测试 fixture,没有连接真实上游数据库,也未部署。
补充全量验证:Go 1.27.1 的 go vet ./... 通过;go test ./... -count=1 -timeout=120s 未通过,既有失败涉及 API 同步口令保留、cluster MySQL fixture nil panic、desktop 数据路径和 syncjob 明文秘密断言。模型及所有协议包通过,不代表真实上游服务器验证。日志为 account-go-test.log 和 account-go-vet.log。git diff --check 通过。
## Read-only JSON synchronization reads
Allow JSON_OBJECT/JSON_ARRAYAGG and pure HEX/UNHEX/LPAD expressions to encode exact batch values through the gateway. Recursive policy checks and source write prohibition are unchanged. See mysql-json-read.md for scope, compatibility and validation.
## Windows startup process discovery (2026-10-06)
Reason: the reported startup output ends at `Stopping previous linkport service...`. The next operation was a CIM/WMI process query without an explicit timeout, which can stall before the existing 10-second process exit wait. This is a potential blocking dependency identified from the script, not a reproduced diagnosis of the user's WMI service.
Result and scope: `scripts/start-dev.ps1` now enumerates processes directly with `Get-Process`, retains exact ownership checks for the project's root and installed executable paths, tolerates inspection/exit races, and fails safely when a live process path cannot be read. Per-process PID/path output, process-check completion, and backup-start messages distinguish the startup stages. Both source and extracted-release launchers use the same code.
Compatibility: no changes to build caching, flags, database selection, backup contents, locks, or install ordering; no schema migration. Unowned copies are never stopped. An unreadable live process path now causes an explicit failure instead of being silently omitted. Existing real data and unrelated working-tree edits are preserved. See `build-output.md` for retry instructions.
Actual validation: the Windows PowerShell startup regression suite passed with compiled executables and isolated temporary data. It stopped fixtures in both owned paths, left an unowned fixture running, and passed source/release, no-owned-process, build-only, backup, and failure-protection cases. No real service or protocol server was used, and no deployment occurred. Go/frontend/Playwright suites were not rerun because no backend, frontend, or embedded assets changed.
## 启动备份排除日志(2026-10-06)
原因:只读排查发现实际数据目录约 30.4 GiB,几乎全部为 audit 分片;备份两次采样从约 10.4 GiB 增长到 12.1 GiB,确认启动等待的是全目录复制。用户明确要求日志不备份。
结果与范围:Windows 源码和发行包共用的 start-dev.ps1 在根层过滤 audit/、logs/ 和 *.log、*.log.*,不递归扫描被排除目录。其余数据继续按原目录结构保存,包含 SQLite 数据库与 WAL/SHM/journal、主密钥、DPAPI 密钥、TLS 和未知非日志配置;显式选择的数据库及其侧文件优先保留。启动提示明确标注排除日志。日志源文件、历史备份及其他工作区改动保持原样。
兼容影响:按用户要求,新启动备份不包含外部审计历史,不再属于完整目录归档;已有数据库中的内嵌历史不改动。数据库 schema、路径选择、进程归属、数据锁和备份后安装顺序不变,无数据迁移。具体排除规则见 build-output.md。
实际验证:Windows PowerShell 启动回归通过,使用临时目录、隔离数据及编译 fixture,覆盖源码/发行包排除审计数据库与 WAL、运行及轮转日志、logs 目录,保留数据库及其侧文件、密钥、TLS 和其他配置,验证源日志不变;已有构建、进程、数据路径与失败保护用例均通过。未部署、未操作真实数据,未连接真实协议服务器。本次无 Go、前端或嵌入资产修改,未重跑对应套件。
## 代理编辑字段按类型显示(2026-10-06)
原因:用户截图中 SOCKS5 编辑器同时展示 Kubeconfig、Kubernetes 和 SSH 指纹。AssetsPage 原先无条件遍历所有类型字段,造成配置范围混淆。
结果与范围:抽取 React OutboundFields 视图,SOCKS5 显示地址/认证字段,SSH 增加指纹,Kubernetes 显示 Kubeconfig 与固定资源字段,sing-box 使用高级 JSON。高级 JSON 提示不再列举其他类型;有效 JSON 对象同步表单类型及字段,避免保存类型与可见字段不一致。覆盖代理新建、编辑及类型切换,详情见 network-proxies.md。
兼容:切换类型保留草稿,不因隐藏字段丢弃原值;已有秘密留空继续省略提交以保留凭据。API、后端校验、引用/指纹确认保护及数据库 schema 不变,无迁移,未部署和修改真实 data。
实际验证:TypeScript/Vite 生产构建及 Go 1.27.1 新嵌入 bin/dev-build/outbound-fields/linkport.exe 成功,保留 web/dist/.gitkeep。LINKPORT_E2E_BINARY 指向该程序,隔离 18751/18752 与临时 SQLite 的新字段用例、React 迁移及 domain Playwright 共 15/15 通过(20.8 秒)。新用例覆盖四种类型字段隔离、下拉/高级 JSON 切换、未完成 JSON、草稿与字段同步、真实管理 API 保存 SOCKS5 和编辑空密码保留;未连接真实协议服务器。全量 148 项以 max-failures=1、15 秒超时在 18753/18754 执行,首项旧 account-editor 在 Vue .target-card 定位处超时,1 失败、147 未运行,另有停止后的 suite 级错误,不宣称全量通过;日志为 bin/dev-build/outbound-fields/playwright-suite.log。构建保留 Node 22.10.0 和约 1.22 MB chunk 提示。无 Go 逻辑修改,未重复 Go 测试/vet。
## 2026-10-06:移除旧格式读取和 UI 适配(进行中)
原因:用户要求检查其他页面并删除前后端旧格式兼容,明确接受旧数据库、旧密钥和旧配置不再可用。原迁移链和 UI 框架适配掩盖了当前字段边界,导致无关协议字段出现在编辑器中。
结果与范围:数据库限定 schema 3,移除自动表结构/账号/作用域/审计/SSH 身份迁移,删除 DPAPI 旧密钥读取和 migrate-data/migrate-key 命令;自动 TLS 不导入旧文件。配置包升级版本 2,旧包拒绝导入。删除 name → remark、顶层网关账号和权限继承适配,保留当前账号数组的凭据省略更新语义;目标内联 SOCKS5/SSH 改为明确拒绝。删除无效的 WebDAV passphrase 参数,沿用当前 JSON 同步格式,WebDAV 登录密码仍脱敏。删除前端框架适配层,页面改用原生 Ant Design,并收紧 TLS、数据库、认证和 Sentinel 字段显示。开发启动保留当前命名互斥锁,删除 desktop.lock 兼容检查,备份继续按用户要求排除日志。
兼容影响:旧库/旧包不再直接打开,不提供自动升级路径;本轮仅在临时 SQLite 与端口验证,没有部署或启动新程序访问实际配置库。未删除真实数据、日志、TLS 或密钥。内联 Kubernetes/TCP 和 S3 设置删除受到自动审批阻挡,暂留功能,因此任务尚未全部完成。
实际验证:TypeScript/Vite 构建通过;新嵌入程序的 outbound-fields/react-migration/ui-domain Playwright 15/15 通过。API、store、syncjob、redisproxy 定向 Go 测试通过;model、desktop、gateway、MySQL、MongoDB、SSH、PostgreSQL、RabbitMQ 等包在全量运行中通过。完整 Go suite 仍有此前已记录的 cluster MySQL fixture 身份协商 nil panic;未将 fixture 结果表述为真实服务器验证。新增全部页面和协议字段专项,最终结果在后续段落补充。
2026-10-06 本轮收尾验证:新嵌入 bin/dev-build/cleanup/linkport.exe,17 项 Playwright 专项全部通过(26.4 秒),其中新增 11 页面导航/pageerror/横向溢出和协议字段测试;36 项 Node domain 测试通过。scripts/start-dev.test.ps1 通过,使用隔离 fixture 验证当前互斥锁、日志排除及原文件保护。Go 1.27.1 全量测试仅 internal/cluster 的既有 TestMySQLReplicationAndGroupDiscovery 发生 go-mysql fixture 身份协商 nil panic,其余包通过;go vet ./... 通过。新增旧配置包/模板版本拒绝测试通过。前端 TypeScript/Vite 构建成功,web/dist/.gitkeep 保留,仍有 Node 22.10.0 低于 Vite 推荐版本和大 JS chunk 提示。未做真实上游或生产部署验证。
完整 Playwright 本轮发现 150 项;以 max-failures=1、15 秒超时运行,首项 account-editor 因旧 Vue `.target-card` 定位失败,149 项未运行,停止后另报 suite 错误。全套未通过;日志见 bin/dev-build/cleanup/full-browser.log,专项日志见 browser-final.log。git diff --check 通过。旧浏览器用例迁移和审批阻挡项尚未完成。
## 2026-10-06:按确认删除 S3 设置存储
原因:用户进一步明确要求“S3 设置存储删”,解除此前该项的审批阻挡。
结果与范围:删除 internal/store/s3_backup.go 的设置模型和读取方法;新配置库不再创建 s3_backup_settings;启动与快照的密钥校验不再扫描 s3_backup_settings 或 s3_sync_settings。移除旧 S3 测试夹具,保留 WebDAV 默认设置、保存及租约互斥测试,并检查新库不存在上述两张 S3 设置表。WebDAV 同步/API 继续使用当前设置,独立 S3 传输帮助代码不属于本次设置存储删除范围。
兼容影响:旧 S3 设置不再读取或转换,新库 schema 不含 S3 表;未增加启动删表迁移,未连接实际配置库或远端对象存储。schema 版本仍为 3,但含旧 S3 表的 SQLite 快照与新库的结构指纹不同,现有离线恢复保护会拒绝直接覆盖。Kubernetes/TCP 的审批阻挡不因本次 S3 确认而解除。
实际验证:Go 1.27.1 全量 `go test ./... -count=1 -timeout=120s` 中 store、api、syncjob 及其他包通过,仍仅 cluster 的既有 MySQL fixture 身份协商 nil panic 失败;`go vet ./...` 通过。TypeScript/Vite 和新嵌入程序构建通过,17 项 Playwright 专项通过(29.6 秒),使用 18761/18762 端口和临时 SQLite。源码检索确认 S3 设置表仅出现在“不应建表”的测试断言中。`git diff --check` 通过,web/dist/.gitkeep 保留。日志位于 bin/dev-build/cleanup/s3-{go-test,vet,build,browser}.log;测试使用协议 fixture,未连接真实 S3/WebDAV 或数据库服务器,也未部署到真实数据目录。
## 网关审计异步消费(2026-10-08)
原因:同步审计持有全局写锁并逐条提交 SQLite,基准约 2.1ms/条,并发没有扩展性。用户明确要求正常转发不等待审计落盘。
行为:网关命令及输出只入内存队列,单个后台消费者按 FIFO 写入,复制输出缓冲与结果指针;连续普通命令最多 64 条、约 1MiB 合并为事务。原同步 Store API 保留供维护、导入和直接调用;协议鉴权与访问控制保持同步。关闭 Store 先拒绝新入队,再等待已接受记录消费完成。
容量和失败语义:队列上限 8192 条、64MiB(另有至多一个正在消费的批次)。满载拒收最新审计记录,写盘失败的批次计为失败,不重试且不回传给业务;写盘失败在消费者中限频记录日志,monitoring 响应 auditQueue 提供 pending/pendingBytes/written/failed/dropped 计数;入队路径不执行日志 I/O。计数为进程全局、内存统计;pending 不含正在消费的批次。异常退出可能丢失尚未提交的记录。这是从强制同步审计变为尽力异步审计的明确行为变化。
兼容和范围:不更改 SQLite schema、密文格式或真实目标数据。记录在审计页面可能稍后出现。仅后台存储与网关回调、监控 JSON 增加字段,不涉及前端资源。验证使用临时 SQLite 和协议 fixtures,非真实上游压测;实际验证:异步专项通过;最终 store/gateway/api 三包全部通过,go vet ./... 和可执行文件构建通过。全量 go test ./... 的其他包通过,cluster 的 TestMySQLReplicationAndGroupDiscovery 在依赖 go-mysql 的 WritePublicKeyAuthPacket 中空指针崩溃;未修改该无关 fixture。旧网关同步审计断言已按最终一致性调整并通过。基准含队列完全落盘:同步并发 2069900 ns/op,异步合并提交 77919 ns/op,约 26.6 倍;不是业务端到端 QPS 结论。构建产物 artifacts/linkport-async-audit.exe,未替换运行实例。
## 2026-10-10 配置管理支持固定启动端口
原因:管理服务默认每次使用随机端口,收藏地址和本地集成不稳定。
行为与范围:配置页增加管理端口输入及字段校验、保存结果、重启提示和环境变量覆盖说明。服务启动读取本机 SQLite 设置,非空 LINKPORT_HTTP_ADDR 优先;start-dev 不再注入随机端口。保存不重绑当前监听、不修改业务网关;固定端口占用时明确启动失败。0 保持自动分配,1–65535 使用指定端口,仍默认绑定回环地址。
兼容与迁移:自动补建 startup_settings 单行表,旧库默认 0,保留所有数据库迁移;不参加资源配置包及 WebDAV 配置同步,完整数据库备份包含此设置。详见 docs/startup-port.md。验证使用临时数据库、隔离进程和本机构造的端口占用,不连接真实数据库服务;实际验证结果记录在该功能文档。
实际验证:前端和可执行文件构建、go vet、启动脚本隔离回归通过;41 项浏览器专项全部通过(46.3 秒),含真实进程重启、占用端口失败及环境变量覆盖。全量 Go 的 cmd/linkport、api、store 等通过,cluster 既有 MySQL fixture 空指针仍失败;pgproxy 身份隔离用例首次失败、独立复跑通过。未修改无关协议实现或测试。页面新增环境变量提示后,备份表单测试明确定位操作错误提示,避免把独立的警告也当作同一个 alert。
## 账号 ID 自动维护(2026-10-13)
原因:账号 ID 已自动生成,展示为必填可编辑字段增加无效操作,并容易破坏账号关联。目标与网关共用编辑器移除该输入框,继续自动生成和保留 ID;后端校验、提交字段、凭据与目标映射保持不变,无数据迁移。复用账号保存浏览器回归,检查输入框不存在、新增 ID 有效,以及编辑后的 ID 和已保存密码保持不变。实际验证见 docs/workspace-ui.md 对应记录。
验证结果:前端与嵌入程序构建通过,12 项浏览器专项全部通过;使用隔离数据和 fixtures。后端无代码改动,未重复运行全量 Go 测试。
## 编辑页布局优化(2026-10-13)
原因:账号选择、增删操作和凭据混排,宽屏表单列数过多,底部测试账号选择器缺少宽度约束。将账号工具栏与字段网格分开,共用详情页限制宽度并居中,编辑表单根据可用空间选择一至两列,底部操作区对齐并允许换行。影响目标、网关和复用详情页的编辑布局,不改主题、业务逻辑、数据格式或迁移。新增桌面、平板和手机布局断言;实际验证记入 docs/workspace-ui.md。
实际验证:前端和嵌入程序构建通过;42 项浏览器专项全部通过(44.5 秒),包括四种屏宽的布局检查及既有保存、校验、主题和退出保护。使用临时数据库与 fixtures,不涉及真实目标。后端无改动,未重复运行全量 Go 测试。
+65
View File
@@ -0,0 +1,65 @@
# 待确认的前端旧测试删除
2026-10-06:下列 53 个文件依赖已删除的 Vue 组件/composables/theme,或旧 `.el-*`、`.target-card`、`.proxy-card`、`.gateway-card` DOM。自动审批拒绝批量删除,理由是其中同时覆盖账号、备份、Kubernetes/TCP、WebDAV 等现用功能。文件仍在,测试发现配置不变。
拟执行的结果是删除这些旧测试文件;不会删除对应的当前功能。当前 React 专项、CSRF 和纯 domain 测试保留,但不能替代下列所有场景的覆盖。若执行删除,会暂时减少相关功能回归覆盖。
路径均相对于 `web/tests/`:
```text
account-editor.spec.ts
account-targets.spec.ts
asset-editor-dialog.spec.ts
assets-backup.spec.ts
audit-pages.test.mjs
automation.spec.ts
cluster.spec.ts
connection-canvas.spec.ts
connection-canvas.test.mjs
connection-diagnostics.spec.ts
connection-stages.spec.ts
connection-url.spec.ts
database-snapshot.spec.ts
decryption.spec.ts
file-assets.spec.ts
gateway-connection-path.spec.ts
gateway-management.spec.ts
global-assets-proxy-project.spec.ts
groups.spec.ts
host-identity.spec.ts
kubeconfig-selection.spec.ts
kubernetes.spec.ts
live-target-switch.spec.ts
log-settings.test.mjs
many-to-one.spec.ts
mongo-connection.spec.ts
mysql-access-modes.spec.ts
optimization.spec.ts
page-dialogs.test.mjs
persistent-target.spec.ts
persistent-target.test.mjs
project-scope.spec.ts
rabbit-version.spec.ts
resource-catalog.test.mjs
running-edit.spec.ts
saved-target-connection.spec.ts
scope-catalog.spec.ts
secret-reveal.test.mjs
sentinel-credentials.spec.ts
sing-box.test.mjs
ssh-forward-skip.spec.ts
target-accounts.spec.ts
target-auth.spec.ts
target-editor-test.spec.ts
target-outbound-selection.spec.ts
tcp-forwarding.spec.ts
tcp-independent.spec.ts
tcp-kube-forward.test.mjs
usability-assets.spec.ts
usability.spec.ts
webdav-sync.spec.ts
workspace-features.spec.ts
workspace.spec.ts
```
另一个被拦截的动作是删除 ConnectionEditor 中独立 Kubernetes/旧 TCP 配置入口及 networkProxy、gatewayTargets、readiness、formValidation、proxyLabels 中相关分支。共享代理方式继续支持这些协议,但删除独立入口会改变可编辑的配置形式,尚未执行。
+1 -1
View File
@@ -177,7 +177,7 @@ flowchart LR
### 7.3 WebDAV 同步与传输
[internal/syncjob/engine.go](../internal/syncjob/engine.go) 管手动/定时调度、锁、租约、取消及完成记录;[transfer.go](../internal/syncjob/transfer.go) 管同步基线、冲突选择和独立加密备份,依赖消费者侧 Repository 与 [ObjectStore](../internal/backup/object_store.go) 条件对象存储接口。WebDAV 适配器复用 [webdav.go](../internal/backup/webdav.go) 的 Basic/匿名认证、目录创建、GET 和条件 PUT。旧 S3 帮助代码只为历史兼容保留,当前页面、API 和调度不再使用。操作及升级说明见 [WebDAV 同步备份](webdav-sync.md)。
[internal/syncjob/engine.go](../internal/syncjob/engine.go) 管手动/定时调度、锁、租约、取消及完成记录;[transfer.go](../internal/syncjob/transfer.go) 管同步基线、冲突选择和独立 JSON 备份,依赖消费者侧 Repository 与 [ObjectStore](../internal/backup/object_store.go) 条件对象存储接口。WebDAV 适配器复用 [webdav.go](../internal/backup/webdav.go) 的 Basic/匿名认证、目录创建、GET 和条件 PUT。S3 设置存储已删除,当前页面、API 和调度不使用 S3;本次未删除独立的 S3 传输帮助代码。操作及格式说明见 [WebDAV 同步备份](webdav-sync.md)。
## 8. 前端页面与公共状态:web/src
+19 -3
View File
@@ -2,7 +2,7 @@
## 模式选择
在网关编辑器的「网关与访问 → 网关访问模式」中选择默认策略;在「账号 → 权限 → 访问模式」中选择继承网关或单独覆盖。MySQL 提供以下三种模式:
在网关编辑器的「账号 → 账号访问模式」中为每个账号独立选择权限。MySQL 提供以下三种模式:
| 模式 | 保存值 | SQL 行为 |
| --- | --- | --- |
@@ -10,9 +10,9 @@
| 读写(默认) | `readwrite` | UPDATE/DELETE 必须有自身的外层 WHERE;双引号 INSERT VALUES 兼容及其他边界见[写入保护](mysql-write-safety.md)。 |
| 全放行 | `unrestricted` | 不执行网关 SQL AST/WHERE 检查,原样转发 SQL、预处理和批次,由目标账号权限及数据库规则决定是否执行。 |
旧配置中的空值或 `readwrite` 保持安全读写行为,不会自动变成普通读写。网关级只读限制优先,不能通过账号选择普通读写绕过;普通读写模式只适用于 MySQL,MongoDB 等其他协议不增加此选项。模式仍使用现有 JSON 配置字段,无数据库迁移;已有连接沿用建立时的策略,修改后重新建立连接使用新模式。
当前账号的空值或 `readwrite` 都使用读写保护,不继承网关权限。`unrestricted` 仅适用于 MySQL;已有连接沿用建立时的策略,重新连接后使用新模式。2026-10-06 已移除旧空模式继承,旧数据库和配置包也不再支持,详见 SQLite 存储文档。
2026-10-02:界面将原“安全读写/普通读写”名称统一为“读写/全放行”,网关默认策略由只读开关改为三选一下拉框。保存值和既有账号策略保持兼容;新建 MySQL 账号默认继承网关,已有账号继续使用保存的显式模式。网关设为全放行时,显式读写账号仍执行写入保护;设为只读时所有账号只读。全放行保留鉴权、来源限制、审计和协议报文检查,不等同跳过数据库权限,也不会自动启用 ANSI_QUOTES。
2026-10-02:界面将原“安全读写/普通读写”名称统一为“读写/全放行”,网关默认策略由只读开关改为三选一下拉框。保存值和既有账号策略保持兼容;新建 MySQL 账号默认继承网关,已有账号继续使用保存的显式模式。网关设为全放行时,显式读写账号仍执行写入保护;旧版设为只读时所有账号只读,该规则自 2026-10-05 改为账号显式模式优先。全放行保留鉴权、来源限制、审计和协议报文检查,不等同跳过数据库权限,也不会自动启用 ANSI_QUOTES。
普通读写模式不固定目标会话 SQL mode,也不在 RESET CONNECTION 后重新设置解析兼容模式,支持数据库自身认可的语法、SQL 动态预处理及会话设置。鉴权、账号目标映射、来源限制、命令审计失败关闭连接和报文格式检查继续适用。它不是绕过网关认证的 TCP 透传模式。
@@ -39,3 +39,19 @@ Binlog 流逐包转发,包括以 `0x00` 开头的事件,不能将首个事
本机 TCP 协议 fixture 覆盖连续 Binlog 事件、序号回绕、GTID、终止错误、断开清理、管理命令和格式拒绝;SQL fixture 覆盖普通模式无 WHERE/会话设置/预处理及重置,保留安全模式拒绝测试。fixture 不执行真实数据库写入,不代表已通过真实 MySQL 复制服务器验证。
2026-09-25 实际结果:Go 1.25.0 的 MySQL、模型包测试、账号模式 API 保存回归和 `go vet ./...` 通过;全量 `go test ./... -count=1 -timeout=120s` 的 6 项既有文件权限/SQLite 快照测试失败,其余包通过,日志位于 `.cache/sqlite-removal/mysql-modes-full-test.log`。前端类型检查及 2 项模式展示逻辑测试通过,新增 Playwright 用例可发现;`npm.cmd --prefix web run build` 在 Vite/esbuild 创建子进程时遇到 `spawn EPERM`,无法生成新的嵌入式二进制运行浏览器套件。
## 网关访问模式归属账号(2026-10-05)
原因:网关级只读覆盖账号显式模式,无法让同一网关中的不同账号分别拥有只读、读写或全放行权限。
结果:GatewayAccount.Apply 使用账号显式 accessMode;空值仍使用网关历史值作为兼容回退。编辑器仅在账号区提供账号访问模式,不再提供继承网关选项;读取旧账号时将空模式展示为原回退模式,新账号默认读写。MySQL 支持只读、读写、全放行,MongoDB 支持只读、读写;SSH/SFTP/TCP 不增加模式。无账号 MongoDB 保留免认证访问模式。列表按有效账号模式汇总,不同模式显示按账号设置。来源限制、认证、审计及目标数据库权限不变。
兼容影响:无需数据库迁移,不修改真实 data。旧空模式账号行为不变;原网关为只读但账号显式为读写或全放行的配置,现在按账号显式权限生效,重连后使用新策略;这是本次按账号授权的行为变更。已建立连接保留原会话策略。旧网关字段保留用于空模式和免认证兼容。
实际验证:前端生产构建通过,Go 1.27.1 重新嵌入 account-linkport.exe,恢复 web/dist/.gitkeep。账号模式模型/API专项通过;Node 12/12 通过;隔离 SQLite、18741/18742 端口的 React/domain Playwright 14/14 通过(18.5 秒),验证网关页无模式、账号页无继承选项及真实 API 保存只读。产物与日志在 bin/dev-build/audit-selects/account-*。使用本机管理 API 与测试 fixture,没有连接真实上游数据库,也未部署。
补充全量验证:Go 1.27.1 的 go vet ./... 通过;go test ./... -count=1 -timeout=120s 未通过,既有失败涉及 API 同步口令保留、cluster MySQL fixture nil panic、desktop 数据路径和 syncjob 明文秘密断言。模型及所有协议包通过,不代表真实上游服务器验证。日志为 account-go-test.log 和 account-go-vet.log。git diff --check 通过。
## 移除权限继承(2026-10-06)
上文 2026-10-02/05 记录属于历史。当前 GatewayAccount.Apply 仅使用账号模式,非 SSH 账号空值独立默认为 readwrite;编辑器和列表不从网关 accessMode 补齐。无账号 MongoDB 的网关模式仍是当前功能。模型、API、协议 fixture 和前端 access-mode 测试通过;不是连接真实数据库验证。
+5
View File
@@ -0,0 +1,5 @@
# Read-only JSON batch queries
The MySQL read-only policy accepts JSON_OBJECT and JSON_ARRAYAGG AST expressions and the pure HEX, UNHEX and LPAD functions. This permits lossless bounded JSON batches for database synchronization without changing read-only permissions. Every nested expression still passes the existing recursive safety checks; writes, locking reads, output files, variables, SLEEP, LOAD_FILE and custom functions remain denied.
No database or configuration migration is required. The change is isolated to SQL policy validation. Policy tests use parser fixtures, including the captured synchronization query and unsafe nested expressions. Full backend tests were run; existing WebDAV, cluster fixture, desktop data-path and syncjob assertions fail in this baseline. Go vet passed. The deployed frontend assets are preserved.
+47 -1
View File
@@ -1,11 +1,52 @@
# 网络代理管理
## SSH 指纹自动展示与确认保存(2026-10-07)
用户无需知道或手填指纹。首次保存 SSH 代理时,页面自动探测主机公钥并显示服务器地址与完整 SHA256 指纹;点击“确认指纹并保存”提交确认及配置,继续启用严格主机身份校验。已保存的指纹变化时同时显示新旧指纹,按钮为“确认新指纹并保存”。探测不发送用户名/密码,确认前不创建或更新代理。
地址/端口变化或探测过期需要重新探测确认;修改用户名/密码不会无故清除当前探测结果。已保存的严格校验主机身份不变时,普通编辑不要求重复确认。新代理从 SOCKS5 默认端口切换到 SSH 时使用 22;自定义端口保留。指纹不再提供普通手填输入,仍可查看已保存值。后端原有会话、端点、指纹、有效期和保存基线绑定继续生效,不自动跳过验证。
## 代理表单交互(2026-10-07)
新建/编辑代理及测试结果使用工作区页面,替代弹框;测试成功/失败颜色和范围说明保留。认证方式使用下拉,SOCKS5 可选无需认证/用户名密码,SSH 仅支持用户名密码;无需认证时隐藏凭据输入。Kubeconfig 资产按名称选择,不再手输 ID。缺失认证凭据会在保存按钮附近显示中文校验错误,服务端失败也保留在当前页面;编辑已保存密码时仍允许留空保留。无协议/API/数据库变更。
## 各类型代理测试(2026-10-06)
测试结果使用独立弹窗:蓝色“测试中”、绿色“测试成功”、红色“测试失败”,同时显示状态图标与文字,支持当前浅深主题。成功保留测试范围及详细结果,失败显示错误原因;重新测试清除旧结果,只有明确 valid/authenticated 成功标志才显示绿色。影响范围弹窗不再与测试结果混用。
所有代理均可调用 `POST /api/v1/outbounds/:id/test`。按用户进一步要求,增加 SOCKS5 与 Kubernetes 测试,替代此前仅提示前往关联目标测试的行为。
| 类型 | 操作 | 测试范围 |
| --- | --- | --- |
| SOCKS5 | SOCKS5 握手测试 | 连接代理,执行 SOCKS5 方法协商及配置的用户名/密码认证,随后断开;不发送 CONNECT 请求 |
| SSH | SSH 连接测试 | SSH 握手、主机身份检查及认证 |
| Kubernetes | Kubernetes 权限测试 | 使用保存的 Kubeconfig/凭据资产访问集群 API,通过 SelfSubjectAccessReview 检查所配置资源的读取、必要的 Pod 列表及端口转发权限 |
| sing-box | 校验配置 | 调用 sing-box 检查配置;不代表实际出站连通 |
SOCKS5 测试最长 10 秒,Kubernetes 请求最长 8 秒,客户端取消时终止请求。Kubernetes 拒绝权限或无法确认权限均返回 422,不误报成功;不返回任意上游响应或凭据。新测试只接受空对象参数,不修改保存配置。Kubernetes 权限检查不验证资源存在、Pod 就绪或实际端口转发;完整目标连接仍从目标服务测试。成功结果带 scope 和中文测试范围说明。
验证使用本机 SOCKS5 TCP fixture、Kubernetes HTTPS fixture 和临时配置库,覆盖握手/认证、取消、权限允许/拒绝、API 不可用及凭据不泄露;没有真实上游代理验收。最终构建、浏览器和全量 Go 结果见 [测试记录](testing.md)。
## 当前格式限制(2026-10-06)
目标不再把内联 SOCKS5/SSH 配置自动转为共享代理。创建或修改时应先保存代理,再提交 `outboundId`;旧内联修改返回 400,现有共享配置保持原样。数据库与配置包版本门槛见 [SQLite 存储](single-file-storage.md)。
内联 Kubernetes 和旧 TCP 模式删除受到自动审批阻挡,相关路径暂留;旧数据库按最新要求保留启动迁移,旧外部配置包仍不直接导入。
## 目的与入口
「代理管理 → 新建代理」用于管理网络连通方式。页面顶部直接选择 Kubeconfig、SSH、SOCKS5 或 sing-box,随后填写名称、环境及该类型的连接配置。不再显示文件资产/连接资产分类,也不提供通用文件管理列表。新建和编辑仍使用工作区独立页面。
Kubeconfig 与 SSH、SOCKS5 一样是独立保存、可被多个服务引用的代理。Kubeconfig 文件的上传是配置代理凭据的一步:在代理内选择或「上传 Kubeconfig」,再确定 Context、命名空间与固定资源。SSH 私钥、证书等也仍在需要它们的表单中选择或上传。已有文件通过资源搜索、引用跳转及配置历史继续维护,不删除文件或丢弃凭据。
## React 代理编辑字段(2026-10-06)
当前 React「代理管理 → 代理配置」的新建和编辑弹窗按代理类型显示连接字段:SOCKS5 只显示代理主机、端口、用户名、密码和认证方式;SSH 在这些字段之外显示主机指纹及探测/确认操作;Kubernetes 只显示 Kubeconfig 资产 ID、命名空间、资源和 Context;sing-box 通过高级 JSON 的 singBoxOutbound 参数配置,不显示其他类型的连接字段。名称、环境、项目、备注仍为共享信息。
原因是原 React 编辑器无条件渲染所有类型字段,导致 SOCKS5 下出现 Kubernetes 和 SSH 配置。独立 OutboundFields 组件现在按类型选择字段。切换类型只改变可见字段,保留当前草稿,切回时可继续编辑;高级 JSON 输入成为有效对象时同步字段值及类型,未输入完成的 JSON 保留原文。已有秘密留空保留、后端类型校验和引用保护继续生效。HTTP API、存储格式不变,无数据迁移。
实际验证:前端 TypeScript/Vite 生产构建和 Go 1.27.1 新嵌入程序 bin/dev-build/outbound-fields/linkport.exe 成功,web/dist/.gitkeep 保留。使用 LINKPORT_E2E_BINARY、18751/18752 端口及临时 SQLite 运行 outbound-fields、react-migration、ui-domain,15/15 通过(20.8 秒),覆盖四种类型字段隔离、下拉/JSON 类型切换、草稿保留、SOCKS5 真实管理 API 保存与编辑空密码保留。没有连接真实 SOCKS5、SSH 或 Kubernetes 服务器,也未部署或修改真实数据。完整套件使用隔离 18753/18754 端口、max-failures=1、15 秒超时:旧 account-editor 依赖 Vue .target-card 而失败,147 项未运行,日志见 bin/dev-build/outbound-fields/playwright-suite.log。前端构建仍有 Node 22.10.0 版本和大 chunk 提示;本轮未修改 Go 逻辑,未重复 Go 测试/vet。
## 关联服务
| 使用方 | 关联方式 | 目的地址与端口 |
@@ -40,6 +81,11 @@ Kubeconfig 代理的 `host` 应为空或省略,资源信息由代理提供;
## 兼容与验证
没有数据库迁移,也不自动搬移或删除已有数据。旧 TCP 空 mode/SOCKS5、目标内联 K8s、TCP 内联 K8s 和已有代理 ID 继续可读写;只有编辑旧内联 K8s 时显示兼容配置。新 UI 使用共享代理。旧执行程序不识别新增 `tcpForward.mode="proxy"`,回退前应使用适配旧版本的备份并核对配置。
2026-10-06 按最新要求保留数据库迁移:启动将旧目标内联代理转为共享引用,将旧 TCP 空 mode/SOCKS5 转为 proxy,并将 TCP 内联 K8s 转为共享 Kubernetes 代理;重复重开不会重复生成代理。前端独立 Kubernetes/TCP 兼容入口的删除仍被自动审批拦截,暂时保留。旧执行程序不识别新增 `tcpForward.mode="proxy"`,回退前应使用适配旧版本的备份并核对配置。本次仅在临时库验证,没有迁移真实数据。
新增 model/store/API 测试覆盖四种代理引用、持久化重开、备份导入、脱敏、间接引用及运行保护;本地 SOCKS5 fixture 验证字节转发。前端逻辑覆盖类型切换、未选/丢失引用、K8s 无 host 和旧配置兼容。实际命令、全量结果及生产构建/浏览器限制见 [测试记录](testing.md)。没有将 fixture 结果当作真实 Kubernetes、SSH 或 sing-box 服务器验收。
## 代理编辑缺项提示(2026-10-07)
代理保存前显示名称、主机、端口、认证凭据或 Kubernetes 资产/资源的具体问题,并标红、定位输入。名称为空时保存按钮不再静默禁用;SSH 指纹仍自动获取后等待用户确认,不要求手填。SOCKS5 用户名/密码按 255 个 UTF-8 字节限制,编辑时已保存密码留空保留;高级 JSON 格式错误定位到编辑框。探测和保存请求失败在当前页显示原因,保留草稿。
本次没有变更代理 API、数据格式或数据库迁移。31 项当前前端专项通过,包含 SOCKS5 保存后编辑保留密码、Kubernetes 缺项和 SSH 指纹确认;使用 HTTP fixtures 及隔离管理 API,不代表真实上游服务器连通性验证。详见 testing.md 的 2026-10-07 记录。
+3 -3
View File
@@ -1,9 +1,9 @@
# 跨平台运行
Windows、Linux、macOS 使用同一单 SQLite 格式:local_metadata 内的主密钥、配置、审计和自动 TLS 均在库内,不读取 LINKPORT_MASTER_KEY。旧库迁移见 [单文件存储](single-file-storage.md)。
Windows、Linux、macOS 使用相同的当前 SQLite 配置格式(schema 3):local_metadata 保存主密钥,配置和自动 TLS 位于主库,审计位于分片。保留旧库升级与原密钥的一次性导入,Windows 支持读取原 DPAPI 密钥;库内已有密钥时直接使用。版本门槛与备份范围见 [SQLite 存储](single-file-storage.md)。
回环管理地址启用本地模式,包括随机端口、可选管理员设置和打开浏览器;非回环部署仍要求认证。Windows 使用命名互斥锁并保留旧锁兼容;Linux/macOS 使用数据库文件锁。新 Windows 数据库 ACL 限制当前用户和 SYSTEM,Unix 数据库权限为 0600,已有过宽权限会拒绝启动,不自动改写。
回环管理地址启用本地模式,包括随机端口、可选管理员设置和打开浏览器;非回环部署仍要求认证。Windows 仅使用命名互斥锁;Linux/macOS 使用数据库文件锁。新 Windows 数据库 ACL 限制当前用户和 SYSTEM,Unix 数据库权限为 0600,已有过宽权限会拒绝启动,不自动改写。
Windows 构建使用 scripts/build.ps1,Linux/macOS 使用 `sh scripts/build.sh`。Windows 单 EXE 打包使用 scripts/package.ps1;当前仓库没有 package.mjs 六目标打包入口或 start-dev.sh,不应调用不存在的脚本。其他 GOOS/GOARCH 可通过 Go 交叉编译,编译通过不等于异系统运行验证。
本次合并保留当前可移植库格式,不采用远端文档中的 local_key 表或分离审计/TLS 格式。实际验证范围见 [测试记录](testing.md)。
实际跨平台验证范围见 [测试记录](testing.md),Windows fixture 通过不代表 Linux/macOS 运行验证。
+97
View File
@@ -0,0 +1,97 @@
# React + Ant Design 前端
## 当前业务页面(2026-10-07)
业务编辑、详情、结果及预览视图统一使用 DetailPage,在主工作区展示,提供返回及底部操作;旧 Dialog 业务弹框入口删除。ConfirmDialog/Modal 仅用于删除、断开关联和放弃修改等短确认。下面的弹框验收属于历史记录。认证方式与 Kubeconfig 选择、错误显示位置及未保存提醒同步调整,详见 [工作区交互](workspace-ui.md)。
## 最新清理范围(2026-10-06)
按最新要求,前端旧适配、旧页面和旧测试属于清理范围;数据库迁移保留,覆盖此前删除后端迁移的决定。后端负责旧库凭据、账号权限和代理引用的转换,前端不再补齐旧字段。S3 设置存储保持删除。
删除旧 ocean 主题到 light 的专用映射;非当前主题值统一采用 system。CSRF 浏览器测试已改用当前 React/Ant Design 的协议选择、账号字段和保存状态,保留过期令牌刷新及只创建一次的行为断言。
清理尚未全部完成:自动审批拒绝批量删除旧 Vue 测试,以及删除独立 Kubernetes/TCP 编辑路径,理由是可能移除现用功能或其覆盖。因此保留被拦截文件和测试发现规则,没有将未运行或旧选择器失败计为通过。
## 原生组件与表单边界(2026-10-06)
删除 `web/src/react/components.tsx` 中沿用旧 UI 框架接口的适配层。各页面直接使用 Ant Design 的 Button、Tabs、Modal、Collapse、Table 和 Typography;表格使用 columns/dataSource,应用 CSS 只承担布局。下列早期迁移记录保留作历史,不代表适配层仍存在。
目标编辑器按协议显示字段:SSH/SFTP 不显示 TLS,TCP 不提供 TLS 终止,CA 仅在启用 TLS 时出现;默认数据库仅属于 MySQL/Redis;Sentinel 认证账号仅在 Redis Sentinel 模式显示。无认证账号不显示用户名/密码,SSH 私钥认证不显示密码。代理管理按 SOCKS5/SSH/Kubernetes/sing-box 类型隔离字段。
删除账号 name → remark、顶层网关凭据 → 账号数组和网关访问模式 → 账号模式的前端补齐逻辑。账号未指定权限时独立默认为读写,不继承网关旧权限。新格式以账号数组及共享代理引用为准。API、存储和桌面路径测试同步使用当前格式。
验证使用临时数据库、隔离端口和重新嵌入前端的程序;当前专项结果见变更记录。旧 Vue `.el-*` / `.target-card` 浏览器用例尚未全部迁移,不能将专项通过宣称为全套通过。
## 界面密度与小屏编辑优化(2026-10-04)
原因:Ant Design 迁移后,查询卡片同时叠加应用 padding 与框架 padding,列设置独占整张卡片,手机编辑器整体高度超过视口,保存与取消需要长距离滚动;导航符号及主题标签也占据重复空间。
结果:查询区删除重复 padding,资源关键词前置,统一收紧字段、页面与标题间距;标题说明使用 Ant Design secondary 文本。列设置改为列表工具栏的 Popover,增加当前筛选结果总数,仍复用原列偏好键。导航使用统一 Ant Design 图标与 Tooltip,保留中文可访问名称、资源计数与小屏图标栏。主题下拉隐藏视觉标签但保留关联 label 和“主题”可访问名称。无资源时提供创建入口,已有资源但筛选为空时提供“清除筛选”,同时清空资源关键词、协议、状态及工作区项目/环境,保留分组和列偏好。
编辑与确认弹窗限制在当前视口内:标题和底部动作固定在弹窗布局中,内容区独立滚动,窄屏底部账号选择独占一行,保存/测试/取消始终可达。使用 Ant Design 默认主题与控件外观,CSS 只调整布局、滚动和视觉隐藏标签。新导航与列设置视图分别位于 WorkspaceNavigation.tsx、resources/ResourceColumns.tsx;App.tsx 继续协调范围状态。
范围与兼容:影响工作区导航、主题工具栏、资源查询/列表及共享弹窗;清除筛选会更新既有非敏感筛选偏好,无新增存储格式、HTTP API 或 SQLite schema 变化,不需数据迁移。凭据省略、未保存确认和请求权限不变;未部署、重启真实服务或修改真实 data。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/ui-polish/linkport-final.exe,以 LINKPORT_E2E_BINARY、临时 SQLite 和 18451/18452 端口运行 React 迁移/domain Playwright,13/13 通过。额外隔离 18431 端口验收空资源创建、空搜索/范围结果清除及列偏好跨刷新保留;11 个菜单 × 浅深主题 × 1440/1024/390/320px 共 88 组检查无 pageerror 或页面横向溢出,8 组编辑器在滚动内容前后保存按钮均完全位于视口内,截图 16 张。浏览器使用本机真实管理 API 和隔离配置;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。日志与截图位于 bin/dev-build/ui-polish。
Go vet ./... 通过。全量 go test ./... -count=1 -timeout=120s 仍失败于 API 的同步口令保留、cluster 的 MySQL fixture nil panic、desktop 的数据路径用例与 syncjob 的两个明文秘密断言,与前轮记录一致;协议包通过但不代表真实服务器验证。首次 Go 与 Playwright 并发时,Go 目录扫描撞上 web/test-results 清理,报目录不存在;浏览器完成后重新执行 Go 检查,上述为重跑结果。
完整旧 Playwright 套件发现 146 项,使用 --max-failures=1 --timeout=15000 运行后,首项 account-editor 因依赖旧 .target-card / “配置目标”失败,145 项未运行,另有停止后的 suite 级错误提示;不能把专项通过视为旧套件全部通过。构建保留 Node 22.10.0 低于 Vite 推荐版本与约 1.22 MB JS chunk 提示。git diff --check 通过。
## 当前实现:Ant Design(2026-10-04)
用户要求将前端改为 React + Ant Design。沿用现有 React 19 页面与 hooks,全部界面控件迁移至 Ant Design 6,并移除 MUI、MUI icons 与 Emotion 依赖。图标使用 `@ant-design/icons`;根组件使用中文 `ConfigProvider` 与默认/深色算法,入口加载 Ant Design reset。应用级组件接口集中在 `web/src/react/components.tsx`,内部使用原生 Ant Design Input、Select、Checkbox、Switch、Tabs、Modal、Collapse、Card、Table、Alert、Tag 和 Typography;表格将页面列与行映射为 Ant Design columns/dataSource,保持关闭分页和横向滚动。布局样式不再依赖 MUI DOM 类名。
范围:登录、导航、资源、编辑器、画布、审计、资产和辅助页。管理 API、Go 协议、SQLite schema、主题偏好键、草稿提交与留空凭据保护沿用当前 React 行为,无数据库或真实 data 迁移。只生成隔离验收程序,不替换运行中的本机服务。下拉与弹窗 DOM 改为 Ant Design,可访问名称继续使用原中文标签。旧 Vue 和历史 MUI 验收记录不代表当前版本通过。
前端 TypeScript / Vite 生产构建通过;Go 1.27.1 重新嵌入 `bin/dev-build/antd/linkport.exe` 后,使用临时 SQLite 与隔离 18391/18392 端口运行 react-migration.spec.ts、ui-domain.spec.ts,13/13 通过(14.6 秒)。三个 Node domain 文件 16/16 通过;`go vet ./...` 通过。最终新程序完成 11 个菜单 × 浅深两种主题 × 1440/1024/390/320px 共 88 组检查,无 pageerror 或页面横向溢出,16 张截图在 `bin/dev-build/antd`。管理请求使用真实本机 API,阶段错误使用 HTTP 响应 fixture;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。恢复 `web/dist/.gitkeep`,未部署或修改真实 data。
`go test ./... -count=1 -timeout=120s` 未通过:API 的 TestWebDAVSyncSettingsAndAuthorization(同步口令未保留)、cluster 的 TestMySQLReplicationAndGroupDiscovery(fixture nil panic)、desktop 的路径相关用例、syncjob 的两个明文秘密断言失败,与既有记录一致;协议包均通过,不能视为真实上游服务器验证。日志见 `bin/dev-build/antd/go-test.log`。构建仍有 Node 22.10.0 低于 Vite 推荐版本及约 1.20 MB JS chunk 提示,构建成功。
完整 Playwright 套件发现 146 项,使用 `--max-failures=5` 执行后 5 项失败、141 项未运行,另有停止后的 suite 级错误提示。失败来自 account-editor(2 项)、account-targets(1 项)、asset-editor-dialog(2 项),仍依赖旧 Vue 的 `.target-card`、`.el-select`、`.connection-editor` 及“配置”“+ 新建目标”等旧文案;未迁移的旧用例不记为通过。日志在 `bin/dev-build/antd/playwright-suite.log`。最终 13 项当前 React 专项通过与完整旧套件结果分别记录,`git diff --check` 通过。
以下为迁移历史。
2026-10-04 按用户要求,先将生产前端从 Vue 3 / Element Plus 改为 React 19 / HeroUI 3,并删除旧主题、控件、响应式 CSS 和 Vue 组件。随后按“使用 MUI”的要求,将全部控件改为原生 MUI 9 与 Emotion;HeroUI 依赖及组件调用已移除。没有保留 Vue 运行时、嵌入旧页面或混合架构。Vite 使用 React 与 Tailwind 插件,生产构建执行 TypeScript 检查。HeroUI 阶段的历史原因及验收记录仍保留在变更和测试文档中。
## 外观与布局
网关、目标和概览的项目/环境与资源查询条件集中在标题下的同一个 MUI Card,范围操作位于卡片底部,窄屏自动换行;详见 [工作区交互](workspace-ui.md)。
控件、卡片、表格、标签、提示、开关、下拉框和弹窗采用 MUI 原生组件及默认主题。`App.tsx` 使用 `ThemeProvider`、`createTheme({ palette: { mode } })` 和 `CssBaseline`,只选择浅色或深色,不自定义 palette、typography 或组件外观。标题和说明使用 `Typography`;卡片使用 `Card` / `CardContent`,表单使用 `TextField` / `MenuItem`,弹窗使用 `DialogTitle` / `DialogContent` / `DialogActions`。没有将 MUI 包装成 HeroUI compound API。`web/src/react/layout.css` 及 Tailwind 工具类仅定义工作区几何布局、间距、滚动、画布位置和移动排列,不覆盖框架的配色、圆角、阴影、控件字体或状态外观。
主题提供浅色、深色、跟随系统,继续使用 `linkport-theme`。原自定义海蓝主题移除,保存的 `ocean` 自动转为浅色。项目、环境和侧栏视图继续使用既有存储键;画布布局继续使用原 scope 布局键。新的列偏好和最近访问使用 React 存储键,旧 Vue 列宽设置不迁移。密码与编辑草稿不存入 localStorage。
## 实现职责
| 入口 | 职责 |
| --- | --- |
| `web/src/main.tsx` | React 根与布局入口 |
| `web/src/react/App.tsx` | 登录、菜单、范围、资源检索、标签、资源刷新、401 清理 |
| `web/src/react/api.ts` | 同源会话、CSRF、错误诊断;只对明确的 CSRF 中间件拒绝重试一次 |
| `web/src/react/ConnectionEditor.tsx` | 目标与网关草稿、账号映射、TLS、出口、集群、URL、保存与测试 |
| `web/src/react/ResourcesPage.tsx` | 列表、启停、引用保护、密码查看、运行状态、指纹、客户端示例 |
| `web/src/react/CanvasPage.tsx` | 拖动、键盘关联、断开确认、布局及缩放 |
| `web/src/react/pages` | 审计、命令输出、资产、配置备份、WebDAV、解密 |
| `web/src/react/pages/ui.tsx` | 原生 MUI 表单、对话框、表格与操作反馈;保留应用级 props 接口 |
| `web/src/react/Operations.tsx` | 健康、告警、日志设置及 Token |
| `web/src/domain` | 保留协议、草稿、校验和凭据提交规则;提取不依赖 UI 的范围、资源与标签模型 |
目标保存后保留编辑器以便执行已保存目标的连接测试;网关保存后关闭。留空已保存密码、私钥等敏感值时继续省略相应提交字段,避免清空凭据。资产编辑留空内容也省略 `content`;编辑运行或被引用资源遵循后端约束。资源密码限时显示,换账号、关闭详情和过期请求不会继续显示迟到结果。
## 交互变化与兼容范围
HTTP API、Go 协议和 SQLite schema 未更改,不需要数据库或真实 data 迁移,也未部署或修改真实 data。MUI 替换只改变前端组件、主题来源和 DOM;项目/环境、偏好存储键、草稿提交规则和上述秘密保护继续沿用 React 版行为。复杂网络代理参数通过完整 JSON 高级编辑,替代旧 Vue 的每字段专用表单;复杂影响、历史对比及审计记录采用可读 JSON。MySQL 审计结果按可读 NDJSON 输出并支持下载,未复刻原专用结果表格。资产需先在代理管理的文件与凭据页创建,再在连接编辑器选择。数据库恢复保持停服 CLI 流程。
删除、覆盖和冲突等操作仍要求显式确认;部分辅助页使用浏览器确认框。移动端保留常驻图标导航,原生标题/中文可访问名称用于导航识别。MUI 下拉控件通过 `combobox` 和 `option` 选择,浏览器验收不再依赖 HeroUI 的 `data-slot` 或按钮式 Select DOM。历史依赖 `.el-*`、Vue 工作区子页面 DOM 或旧文案的浏览器用例仍需要迁移;不能把这些旧用例失败计为通过。
## 验证
连接详情测试失败若未收到服务器 stage,不再生成 unknown 阶段结论:显示管理请求失败提示、HTTP 状态(若有)和不含查询参数的 API 路径。服务器明确返回的阶段仍采用既有诊断解释;不展示任意响应正文,不推断密码或目标网络状态。2026-10-04 新构建程序隔离 Playwright 13 项通过,包含无阶段 HTTP 400 / 有阶段 422 的响应 fixture 回归与敏感正文不显示检查。
MUI 生产构建、Go 1.27.1 新嵌入 `bin/dev-build/mui/linkport.exe`、隔离 18241/18242 及临时 SQLite 的 13 项迁移/domain Playwright 完整复测通过(11.5 秒);三个 Node domain 文件共 16 项通过,`git diff --check` 通过。首次 DialogTitle 重复 id 导致的三项可访问名称失败已修正后重新构建和完整复测,历史记录保留。1440、1024、390、320px 额外检查无横向溢出或 pageerror,亮暗、移动与编辑器截图在 `bin/dev-build/mui`。
完整实际记录见 [变更原因](change-rationale.md) 与 [测试记录](testing.md)。浏览器验收使用当前前端构建后重新嵌入的程序,没有沿用 HeroUI 产物。本轮无后端或真实 data 变更,未重复后端包测试,也未验证真实 Kubernetes、SSH、数据库或 WebDAV 服务器;旧 Vue suite 和前轮已知后端失败不能据此视为通过。
+6 -2
View File
@@ -1,5 +1,9 @@
# S3 同步迁移说明
# S3 设置存储已移除
S3 多端同步已由 [WebDAV 同步备份](webdav-sync.md) 替代。原页面、自动同步及 `/api/v1/configuration/s3/*` API 不再启用。
旧 S3 设置、状态和远端文件保留,不自动迁移或继续上传。WebDAV 使用独立设置与基线,自动同步默认关闭;请按新文档重新配置。部署前须停服并备份完整数据目录。
2026-10-06 按用户明确要求删除 S3 设置存储:不再创建 `s3_backup_settings`,删除设置模型/读取方法,以及启动和快照检查中的 `s3_backup_settings` / `s3_sync_settings` 密钥校验。旧 S3 设置不再读取或迁移,不能恢复为可用的 S3 配置;WebDAV 使用独立设置与基线,自动同步默认关闭。
此次仅修改代码和隔离测试库,未操作真实数据库或远端文件,也不增加启动时删表迁移。配置库版本仍为 schema 3;离线快照恢复会校验完整 schema,包含旧 S3 表的快照与新库结构不同,不能直接覆盖恢复。
验证:存储/API/同步测试及 Go vet 通过,前端与新程序构建成功,17 项隔离 Playwright 专项通过。Go 全量仍有既有 cluster MySQL fixture 崩溃;详细执行记录见 [变更原因](change-rationale.md)。未验证真实 S3 或 WebDAV 服务。
+21 -21
View File
@@ -1,36 +1,36 @@
# 单 SQLite 持久化
# SQLite 配置存储
## 当前行为(2026-09-21)
## 当前格式(2026-10-06)
Windows、Linux、macOS 使用同一套单文件存储。新建数据目录只需要 `linkport.sqlite`;管理员、目标、网关、代理、文件资产、历史配置、审计命令、输出、连接事件、内部随机主密钥和自动 TLS 证书/私钥都保存在其中。应用不再读取 `LINKPORT_MASTER_KEY`,不提供外部主密钥运行模式,也不再提供 `generate-key` 命令。
配置数据库使用 `PRAGMA user_version=3`,保存管理员、目标、网关、共享代理、文件资产、配置历史、随机主密钥和自动 TLS 身份。命令、输出及连接事件使用当前 `audit/` 分片;完整灾备需要同时保存这些分片。默认数据库为用户主目录 `.linkport/linkport.sqlite`,可用 `LINKPORT_DATA` 显式指定;不再搜索工作目录或 AppData 的旧路径。
默认位置为工作目录 `data/linkport.sqlite`,可用 `LINKPORT_DATA` 指定。Windows 回环管理模式保留当前目录优先、旧 AppData 次之的查找行为;`linkport data-path` 只查询路径,不初始化数据。Linux/macOS 不使用 Windows AppData。登录、远程管理认证、CSRF 与协议权限限制不因密钥入库而放宽。
新空库自动初始化。按最新要求保留数据库迁移:支持版本 0–3 的旧库升级,保留旧 proxies、账号名称与凭据、作用域、审计拆分和 SSH 主机密钥迁移。迁移全部成功后才写入 schema 3;未来版本拒绝打开。外置密钥旧库需要原密钥,Windows 可读取原 DPAPI 密钥;错误密钥不会被替代密钥覆盖。保留 `migrate-data`、`migrate-key` 命令和旧 `tls/cert.pem` / `tls/key.pem` 的身份导入。
SQLite 在运行、写入或异常退出后可能存在 `-wal`、`-shm`、`-journal` 文件。这些不是额外的业务数据库,**但不能在运行时删除,也不能在未完成 checkpoint 时只复制主文件**。正常关闭所有数据库连接后才可仅备份/迁移 SQLite。程序被强制终止后应先恢复并正常关闭,或使用 SQLite 一致性备份工具。Windows 使用命名互斥量,Linux/macOS 使用主数据库文件的 advisory flock,不为新库创建 `desktop.lock`。
最新范围覆盖此前“旧格式全部不可用”的决定:前端删除旧适配,数据库继续负责将持久化旧格式转换为当前格式,包括网关账号权限及内联代理转共享引用。不要手工修改版本号。实际部署迁移前必须停服并完整备份数据目录;本次只使用临时库验证,没有部署或修改真实数据。
## 安全边界
配置包、模板和 WebDAV 同步内容使用 `version=2`,旧 `version=1` 配置包拒绝导入。加密配置导出的外层 AES-GCM envelope 仍为版本 1,但内部必须是版本 2 配置包;它与 SQLite schema 版本是不同概念。WebDAV 使用当前 JSON 配置包,旧同步 passphrase 参数已移除并由 API 拒绝;WebDAV 登录密码继续保密、省略时仅在同一端点/账号下保留。
字段仍使用随机密钥 AES-GCM 编码,但密钥与密文在同一个 SQLite 内,因此不再提供对数据库文件持有者的保密隔离。拿到完整数据库即能恢复保存的凭据。文件必须视同包含明文密码的敏感材料:限制目录/文件权限,使用磁盘加密;离机备份另行加密。Windows 不再绑定 DPAPI 用户或机器,Linux/macOS 新建文件权限为 `0600`,已有文件权限不自动改写。
## 文件与备份
## 从旧版本迁移
Windows 使用命名互斥量,Unix 使用数据库文件锁;启动脚本不再读取旧 `desktop.lock`。运行时可能产生 `-wal`、`-shm`、`-journal`,不得运行中删除或只复制主库。离线完整灾备应保存配置库、边文件、审计分片和主动使用的外部文件。
1. 停止旧服务,备份**整个旧数据目录**,包括 SQLite、WAL、`audit/`、`tls/` 和 `master-key.dpapi`。不要让新旧版本同时运行。
2. Windows DPAPI 旧库:在原 Windows 机器、原用户下运行 `linkport migrate-data`。该命令迁移后退出,不监听端口、不启动网关、不连接真实目标。正常启动也可执行同样的初始化,但升级建议先运行离线命令。
3. 旧外部主密钥库:运行 `linkport migrate-key`,在不回显的终端提示中输入原 Base64 密钥。它仅用于一次性解密校验/入库,不重新引入环境变量运行模式。原密钥丢失无法恢复;不会生成替代密钥覆盖旧库。
4. 审计分库按日期顺序合并,命令输出分块保持顺序。每个分库在一个事务中写入及记录迁移标记,中断后重试不重复导入;冲突报错。全部完成后不再读取旧审计目录,新日志只写主库。审计分页游标/输出序号是内部定位值,迁移后须重新加载列表,不复用旧页面游标。
5. 自动 TLS 导入旧 `tls/cert.pem` 和 `tls/key.pem`,不更换有效身份;新库自动生成 localhost 自签名证书。损坏、缺半边的旧证书直接报错,不偷偷重建。
6. 迁移不删除旧 DPAPI、审计或 TLS 文件;保留作回退材料。确认迁移成功并停服后,复制**只有 SQLite**到一个新目录即可独立运行,旧目录可作为归档。后续不再需要外部密钥文件。不要用旧版程序直接打开升级后的数据库;回退请恢复完整升级前备份。
按用户要求,`start-dev.cmd` 的开发启动备份跳过根目录 `audit/`、`logs/`、`*.log`、`*.log.*`,保留数据库及 SQLite 边文件、证书、密钥与其他配置;不会删除源日志。这份启动备份不包含审计历史。
初始化元数据表与密钥行需原子提交,防止崩溃留下“表存在但密钥未写入”的数据库。密钥已有但损坏或缺失时拒绝启动,不自动替换。审计导入事务用于保证数据与迁移标记一致,不涉及外部调用;日常保留期清理先按时间索引查最多 500 个 rowid,再按这些 ID 删除,级联删除对应输出。
主密钥与密文存于同一 SQLite,持有完整库即能恢复凭据,应按敏感文件保护。自动 TLS 从库内读取或新建;显式 `LINKPORT_TLS_CERT` / `LINKPORT_TLS_KEY` 仍是当前自定义证书功能。`export-ca` 只导出公开证书。
## TLS 与可移植性的范围
## 验证与剩余项
自动证书默认覆盖 localhost、127.0.0.1、::1,有效期一年。停止服务后运行 `linkport export-ca`,标准输出为公开 CA PEM,可另存后配置客户端信任,私钥不导出。该导出文件只供客户端使用,不是服务启动依赖。
隔离测试覆盖未来数据库拒绝且文件内容不变、旧库迁移及重复重开、外置密钥导入、错误密钥拒绝、快照校验、TLS 持久化、账号秘密省略保留和新配置包导入。协议检查使用仓库 fixture,不代表真实上游服务器验收。
显式的 `LINKPORT_TLS_CERT` / `LINKPORT_TLS_KEY` 自定义证书仍优先,适用于正式域名/证书轮换;使用者主动配置的外部文件仍需自行管理。目标中主动填写的 SSH/kubeconfig 等文件路径也不会自动吸收入库;要完全可移植,请上传为文件资产并使用资产引用。WebDAV 配置备份仍是原配置包语义,不等同于包含审计和主密钥的 SQLite 灾备。
用户进一步确认后,S3 设置模型、读取、建表及旧设置密钥校验已删除;新库不含 S3 设置表,不对真实旧库执行删表迁移。旧内联 Kubernetes / TCP 转发路径仍因自动审批阻挡暂留,因此尚不能声明所有兼容代码已清理完毕。完整执行结果和限制见 [变更原因](change-rationale.md)。
## 验证
新增隔离 SQLite 测试覆盖:新库仅留主文件、只复制 SQLite 后管理员/命令/输出/TLS 恢复、旧密钥迁移及错误密钥拒绝、跨日审计导入和重启幂等、分页、保留期级联清理。Windows 定向测试覆盖真实 DPAPI 旧密钥读取与无文件进程锁。完整验证结果记录于 `docs/change-rationale.md`;未将 fixture 测试视为真实上游服务器验证。
## 网关审计异步消费(2026-10-08)
2026-09-21 本机按用户明确要求,在完整备份、离线迁移及单文件副本验证后删除了运行目录的 `master-key.dpapi`,复核没有重新生成。此项为已授权的本机清理,不是启动时自动删除其他旧目录的策略。Go 全量测试、vet、三平台编译通过;前端构建和 Playwright 受环境子进程权限限制,详见变更记录。
原因:同步审计持有全局写锁并逐条提交 SQLite,基准约 2.1ms/条,并发没有扩展性。用户明确要求正常转发不等待审计落盘。
行为:网关命令及输出只入内存队列,单个后台消费者按 FIFO 写入,复制输出缓冲与结果指针;连续普通命令最多 64 条、约 1MiB 合并为事务。原同步 Store API 保留供维护、导入和直接调用;协议鉴权与访问控制保持同步。关闭 Store 先拒绝新入队,再等待已接受记录消费完成。
容量和失败语义:队列上限 8192 条、64MiB(另有至多一个正在消费的批次)。满载拒收最新审计记录,写盘失败的批次计为失败,不重试且不回传给业务;写盘失败在消费者中限频记录日志,monitoring 响应 auditQueue 提供 pending/pendingBytes/written/failed/dropped 计数;入队路径不执行日志 I/O。计数为进程全局、内存统计;pending 不含正在消费的批次。异常退出可能丢失尚未提交的记录。这是从强制同步审计变为尽力异步审计的明确行为变化。
兼容和范围:不更改 SQLite schema、密文格式或真实目标数据。记录在审计页面可能稍后出现。仅后台存储与网关回调、监控 JSON 增加字段,不涉及前端资源。验证使用临时 SQLite 和协议 fixtures,非真实上游压测;实际验证:异步专项通过;最终 store/gateway/api 三包全部通过,go vet ./... 和可执行文件构建通过。全量 go test ./... 的其他包通过,cluster 的 TestMySQLReplicationAndGroupDiscovery 在依赖 go-mysql 的 WritePublicKeyAuthPacket 中空指针崩溃;未修改该无关 fixture。旧网关同步审计断言已按最终一致性调整并通过。基准含队列完全落盘:同步并发 2069900 ns/op,异步合并提交 77919 ns/op,约 26.6 倍;不是业务端到端 QPS 结论。构建产物 artifacts/linkport-async-audit.exe,未替换运行实例。
+15
View File
@@ -0,0 +1,15 @@
# 项目启动端口
配置管理 → 项目启动端口中设置管理端口,保存后下次启动生效。1–65535 为固定端口,0 为自动分配(默认)。当前服务和业务网关监听端口不受保存操作影响。页面显示当前访问地址及保存成功/失败提示,并对空值、非整数、越界输入显示字段错误。
启动优先级:非空 `LINKPORT_HTTP_ADDR` 环境变量 > 数据库保存的管理端口 > 默认自动分配。页面显示环境变量覆盖提示;`start-dev.cmd` 不再主动注入随机端口环境变量。数据库设置只控制端口,默认仍绑定 `127.0.0.1`,不扩大网络访问权限。
端口占用时启动报错,不会静默切换随机端口。可以释放端口,或临时设置 `LINKPORT_HTTP_ADDR=127.0.0.1:0` 启动后修改设置,再清除变量重新启动。
新增本机 `startup_settings` 单行表,旧数据库打开时自动补建并默认 0;保留现有数据库迁移。设置不参加配置包导入导出或 WebDAV 配置同步,完整 SQLite 备份包含此设置。无需修改已有网关和目标配置。本次开发不操作真实数据库或重启实际服务。
## 验证(2026-10-10)
前端构建及 Go 1.27.1 可执行文件构建通过;`go vet ./...`、启动脚本隔离回归通过。使用新构建程序执行 41 项当前页面 Playwright 专项,全部通过(46.3 秒),覆盖端口输入错误、保存/回读、实际进程重启保持地址、端口占用拒绝启动及环境变量覆盖。使用临时 SQLite、隔离服务进程和本机 TCP 占位 fixture,不代表真实目标协议服务器验证。
全量 `go test ./... -count=1 -timeout=120s` 中 cmd/linkport、api、store(含新增端口用例)等通过;仍存在无关 cluster MySQL fixture 空指针崩溃。此外 pgproxy 的 TestPreparedRejectsDifferentIdentity/account 首次失败,单独复跑通过;未修改这些协议测试。完整日志位于 bin/dev-build/cleanup/startup-go-test.log、startup-vet.log、startup-script.log、startup-browser-final.log 和 startup-pg-recheck.log。
+19 -1
View File
@@ -1,4 +1,22 @@
# 已保存配置的连接测试
# 目标与网关连接测试
## 编辑页草稿测试恢复(2026-10-07)
原因:详情页可测试已保存配置,但 React 目标编辑页曾强制先保存,未调用已有草稿接口,结果也仅以 JSON 展示。
现在目标编辑页通过 `POST /api/v1/targets/test` 测试当前输入,携带 config、accountId,编辑已有目标时另带 targetId。保存与测试共用 payload 转换,留空的已保存秘密不随请求重复提交,由服务端按资源及账号 ID 解析。只校验所选账号及 Sentinel 依赖账号,不因其他未完成账号阻止测试;空名称允许使用服务端测试名称。该操作不保存目标、配置历史或网关运行配置。
SSH 草稿的 draft_host_confirmation 返回指纹时,先显示服务器地址和完整指纹,用户确认才带 hostFingerprint 重试测试接口,不自动信任或保存。集群识别仍要求保存配置。网关草稿测试不再在指定账号不可用时悄悄换成其他账号;多项可测试账号需明确选择。
编辑页与详情页共享 Diagnostics:绿色成功、红色失败、阶段与脱敏轨迹;编辑页结果自动滚动并聚焦。本地缺少账号等前置条件失败显示具体原因。
兼容影响:恢复已有 API 的前端接入,未修改后端协议、数据库格式或迁移。新增浏览器 HTTP fixtures 覆盖未保存草稿、修改地址后的再次测试、SSH 指纹确认,真实隔离管理 API 的保存流程验证已有目标测试请求携带 targetId 且省略密码。既有 `TestTargetDraft*` Go API fixture 测试通过。完整本轮结果见 testing.md;没有进行真实上游服务器验收。
## 会话阶段未知错误(2026-10-06)
目标测试包含代理及目标协议两个部分,代理握手成功不代表目标协议会话已建立。若观测到 protocol_session 失败但错误类型为 unknown/network/空,返回 target_session,界面显示“目标会话建立失败 · 无法确定”;不能仅凭会话阶段失败断言目标返回无效协议、TLS 错误或密码错误。仅明确 protocol 类型保持 target_protocol,明确认证/权限拒绝仍使用对应分类。已成功的前置阶段继续按实际轨迹显示。
本轮对用户提供的运行服务测试接口进行一次复现:SOCKS5 TCP、方法协商、认证、到目标 TCP 均成功,MySQL protocol_session 失败且 kind=unknown。当前接口没有具体驱动错误,实际 MySQL 失败根因尚未确定;没有改动目标、代理、TLS 或账号配置。分类修复使用隔离 fixture 验证,实际连接问题不能据此宣布修复。
## 改动原因
+152
View File
@@ -1,5 +1,92 @@
# 测试与版本矩阵
## 2026-10-07 SSH 自动指纹确认保存
TypeScript/Vite 和 Go 1.27.1 新嵌入程序构建通过。LINKPORT_E2E_BINARY 指向 bin/dev-build/cleanup/linkport.exe,临时 SQLite 与 18761/18762 隔离端口的六个当前 spec 共 22 项通过(28.4 秒)。新增 HTTP fixture 验证保存自动获取并显示完整指纹,探测只提交 host/port,确认前不保存,修改地址移除旧确认、过期结果重新探测,最终“确认新指纹并保存”提交 probeId/fingerprint 和 skipHostKeyVerify=false。保留页面、表单、CSRF 与凭据省略回归。
本轮仅前端变化,复用原后端主机确认绑定,没有重复后端测试;先前全量 Go 的已知 MySQL 集群 fixture 失败仍未解决。该验证不代表真实 SSH 服务验收,未部署或修改真实配置。git diff --check 通过。日志:bin/dev-build/cleanup/ssh-fingerprint-{build,browser}.log。
## 2026-10-07 业务页面与代理表单
TypeScript/Vite 构建及 Go 1.27.1 重新嵌入 bin/dev-build/cleanup/linkport.exe 通过,保留 web/dist/.gitkeep。最终 LINKPORT_E2E_BINARY 指向该程序,使用临时 SQLite 和 18761/18762 运行当前六个 spec,共 21 项通过(25.0 秒)。验证内容包括:目标/网关/代理/资产页面保存与敏感值留空保留、目标详情返回、未保存确认、代理认证下拉与按模式字段显示、错误紧邻保存按钮且位于视口、服务端 400 错误只显示在当前页、返回不残留错误、菜单切换取消后保留草稿及确认后进入目标页面、320/390px 无横向溢出、全导航与主题回归。
资源保存使用隔离本机 API;代理测试状态和服务端保存错误使用 HTTP fixture,不代表真实代理连通验证。无后端修改,未重复上一轮已执行的全量 Go/vet;既有 MySQL 集群 fixture 失败仍保留,旧 Vue 全套也未因此视为通过,发现规则未收窄。git diff --check 通过。日志:bin/dev-build/cleanup/detail-pages-{build,browser}.log。未替换运行服务或修改真实数据。
## 2026-10-06 未知会话失败分类
Go 1.27.1 gateway/API 专项通过;全量 go test 除既有 internal/cluster/TestMySQLReplicationAndGroupDiscovery 的 go-mysql 认证空指针外均通过,go vet 通过。前端 TypeScript/Vite 与新嵌入程序构建通过;临时 SQLite、隔离 18761/18762 的当前六个 Playwright spec 共 20 项通过(33.7 秒),其中 422 HTTP fixture 验证 target_session 提示且不显示无证据的目标协议异常。git diff --check 通过,web/dist/.gitkeep 保留。
日志:bin/dev-build/cleanup/session-diagnostics-{tests,go-test,vet,build,browser}.log。另对用户现有目标接口复现一次:SOCKS5 及目标 TCP 成功,MySQL 会话失败且原因类型未知;没有修改配置或替换运行服务,实际 MySQL 连接失败尚未解决。此观察与隔离回归测试分开记录。
## 2026-10-06 代理测试颜色标识
前端构建及重新嵌入 bin/dev-build/cleanup/linkport.exe 通过。隔离 18761/18762 和临时 SQLite 的 outbound-fields/current-pages 共 4 项通过(13.7 秒);既有类型测试新增 Ant Design 成功/失败语义颜色、状态文字、错误原因以及成功转失败后旧状态消失断言。使用 HTTP fixture 模拟测试响应,不代表真实上游验证。无后端修改,沿用上一轮 Go 检查。日志:bin/dev-build/cleanup/test-colors-{build,browser}.log。
## 2026-10-06 SOCKS5 / Kubernetes 代理测试
Go 1.27.1 的 outbound/API 专项通过。新测试使用本地 SOCKS5 TCP fixture,覆盖无认证/密码认证、认证拒绝、非 SOCKS5 响应、取消后断开且不发起目标 CONNECT;API 验证 CSRF 和空参数约束。Kubernetes HTTPS fixture 验证凭据资产解析、权限允许/拒绝、API 503 和敏感响应不泄露。没有真实集群或外部代理验证。
最终 `go test ./... -count=1 -timeout=120s`:API、outbound 及其余包通过,仅既有 internal/cluster/TestMySQLReplicationAndGroupDiscovery 的 go-mysql 认证空指针失败。`go vet ./...` 通过。TypeScript/Vite 和新嵌入 bin/dev-build/cleanup/linkport.exe 构建通过,保留 web/dist/.gitkeep;临时库和 18761/18762 上当前六个 Playwright spec 共 20 项通过(31.2 秒),验证四种类型均有测试入口,保留全页面及字段隔离回归。UI 类型操作使用 HTTP fixture,协议实现由 Go TCP/HTTPS fixture 验证。完整旧 Vue 套件仍未通过,本轮未改发现范围。
`git diff --check` 通过。日志:bin/dev-build/cleanup/proxy-probe-{tests,go-test,vet,build,browser}.log。未更新运行中的服务或真实数据。
## 2026-10-06 代理测试入口类型回归
TypeScript/Vite 与重新嵌入程序构建通过。使用 bin/dev-build/cleanup/linkport.exe、临时 SQLite、隔离 18761/18762,outbound-fields/current-pages 共 4 项通过(12.8 秒)。新增 HTTP fixture 断言 SOCKS5/Kubernetes 不显示不支持的测试按钮,SSH/sing-box 各自发起正确测试请求;原字段隔离与 SOCKS5 凭据保留使用本机 API 验证。无后端改动,沿用本轮已执行的 Go 检查结果;没有真实上游连接验证。日志为 bin/dev-build/cleanup/outbound-action-{build,browser}.log。
## 2026-10-06 保留数据库迁移
使用 Go 1.27.1:store/desktop/cmd/linkport 专项全部通过;新版本 2 数据库 fixture 验证网关密码与 readonly 权限保留、SOCKS5 目标和 Kubernetes TCP 共享引用迁移,以及二次重开不重复生成代理。原旧密钥、错误密钥、TLS、账号/备注/作用域迁移测试保留并通过。仅操作临时库。
`go test ./... -count=1 -timeout=120s` 的 internal/cluster/TestMySQLReplicationAndGroupDiscovery 因既有 go-mysql WritePublicKeyAuthPacket 空指针失败;其他包通过。`go vet ./...` 通过。没有验证真实 MySQL/Kubernetes/SSH/WebDAV 上游。
前端 TypeScript/Vite 与新嵌入程序构建通过,保留 web/dist/.gitkeep。临时 SQLite、端口 18761/18762、LINKPORT_E2E_BINARY 指向 bin/dev-build/cleanup/linkport.exe;当前六个 spec 在最终主题映射删除后的构建上共 19 项通过(33.0 秒),包含全导航、协议字段、SOCKS5 保存、CSRF 刷新、主题和 domain。最终日志:bin/dev-build/cleanup/final-{build,browser,go-test,vet}.log。git diff --check 通过。Node 22.10.0 仍低于 Vite 推荐版本,构建有大 chunk 提示。
完整旧 Vue 套件未通过:此前 150 项中 account-editor 旧选择器失败、149 项未运行。本轮未重复执行同一旧套件,也未删除被审批拦截的测试或修改发现范围。待删除清单及覆盖影响见 [前端清理剩余项](frontend-cleanup-pending.md)。
## 2026-10-04 React / MUI 默认主题
本轮将 React 界面从 HeroUI 替换为原生 MUI 9 / Emotion,主题使用 `ThemeProvider`、`CssBaseline` 和默认浅深 palette.mode。辅助页及 Operations 独立严格 TypeScript 检查通过。`react-migration.spec.ts` 已将主题、目标和资产选择器改为 MUI combobox / option,并补充资产编辑空 content 不提交、secretStatus 保留和保存后不意外打开影响范围对话框的回归检查。
生产构建通过。构建结束后使用 Go 1.27.1 重新嵌入 `bin/dev-build/mui/linkport.exe`(2026-10-04 17:40),通过 `LINKPORT_E2E_BINARY` 指定该程序,在隔离端口 18241/18242 和临时 SQLite 上完整执行 `react-migration.spec.ts` / `ui-domain.spec.ts`:13 项通过(11.5 秒)。另执行 `node --test tests/connection-draft.test.mjs tests/account-mapping.test.mjs tests/access-mode.test.mjs`:16 项通过;`git diff --check` 通过。
首次 13 项运行 10 项通过、3 项因 MUI DialogTitle 自动 id 与自定义标题关联重复,可访问名称包含取消按钮文字失败。修正关联后重新构建并完整复测,上述 13 项通过来自最终程序,保留首次失败记录,不称首次零失败。额外浏览器检查 1440、1024、390、320px 均无横向溢出和 pageerror;亮暗、移动及编辑器截图位于 `bin/dev-build/mui`。前轮 HeroUI 程序、截图及通过记录不作为 MUI 验收。
```powershell
$env:LINKPORT_E2E_BINARY='C:/work/github/LinkPort/bin/dev-build/mui/linkport.exe'
$env:LINKPORT_E2E_PORT='18241'
$env:LINKPORT_E2E_AUTH_PORT='18242'
Push-Location web
npx.cmd playwright test react-migration.spec.ts ui-domain.spec.ts
node --test tests/connection-draft.test.mjs tests/account-mapping.test.mjs tests/access-mode.test.mjs
Pop-Location
```
本轮未修改 Go、HTTP API 或 SQLite schema,也无真实 data 迁移或部署,未重复未修改的后端包测试。旧 Vue DOM 用例和已记录的后端失败保持原记录,不宣称全部旧 suite 或全仓回归通过;没有真实协议或 WebDAV 远端服务器验证。实现与兼容边界见 [React / MUI 前端](react-migration.md)。
下文保留此前 HeroUI 和 Vue 阶段的历史验证记录。
## 2026-10-04 React / HeroUI 默认样式迁移
生产构建 `npm.cmd --prefix web run build`、Go 1.27.1 嵌入构建与 `go vet ./...` 通过。最终浏览器程序为 `bin/dev-build/react-final/linkport.exe`,使用独立临时数据库。
```powershell
$env:LINKPORT_E2E_BINARY='C:/work/github/LinkPort/bin/dev-build/react-final/linkport.exe'
$env:LINKPORT_E2E_PORT='18191'
$env:LINKPORT_E2E_AUTH_PORT='18192'
Push-Location web
npx.cmd playwright test react-migration.spec.ts ui-domain.spec.ts
Pop-Location
```
实际在 `web` 中执行 `npx.cmd playwright test react-migration.spec.ts ui-domain.spec.ts`,13 项通过,耗时 12.5 秒。覆盖免登录、真实 API 保存目标/网关、空密码保留、认证退出、未保存保护、浅深/系统主题及 ocean 兼容、资产/审计/配置页、320/390px 布局。另执行 `node --test tests/connection-draft.test.mjs tests/account-mapping.test.mjs tests/access-mode.test.mjs`,16 项通过。辅助隔离浏览器和 HTTP 检查覆盖秘密保留、Token、WebDAV 设置、备份版本冲突、历史恢复、告警和 AES fixture 解密。
全量 Playwright 发现 146 项;保留的旧 Vue 用例在旧 DOM/文案定位处失败,按 `--max-failures=5` 上限停止,不记为全套通过,也未禁用旧用例。重跑最终程序时使用独立 18211/18212 端口及 `--timeout=10000 --output=test-results/legacy-react`。旧 Node 源文件结构断言也需随 Vue 文件删除进行迁移,不能将其视为当前 React 验收。
后端 `go test ./... -count=1 -timeout=120s` 仍存在之前已记录的 API WebDAV 口令、cluster MySQL RSA fixture、desktop Windows 数据路径与 syncjob 加密旧断言失败。没有修改后端实现。所有 protocol 包通过,但没有真实远端协议服务器验收。
桌面、编辑器、浅深移动截图位于 `bin/dev-build/react-final`。旧主题、Vue 控件和全宽页面截图不代表当前 HeroUI 默认样式。迁移说明见 [React 前端](react-migration.md),原因与兼容影响见 [变更记录](change-rationale.md)。
## 2026-09-27 独立网络代理与服务关联
- 前端管理入口改为代理;Kubeconfig、SSH、SOCKS5、sing-box 独立创建,目标和 TCP 网关按类型引用。新增 `network-proxies.test.mjs` 11/11,覆盖切换清空、不完整/失效引用阻止保存、只提交 ID、Kubeconfig 无 host、测试可用性、旧内联兼容和新 API 错误的中文/英文/日文映射。
@@ -570,3 +657,68 @@ Protocol coverage uses local RESP/`net.Pipe` fixtures, not live Redis or Sentine
server and browser checks were not run. Only static source inspection and
`git diff --check` are claimed. The fixtures do not connect to a live MySQL
server, and no real target or `data` directory was modified.
## React + Ant Design 验证(2026-10-04)
前端 TypeScript / Vite 生产构建通过;Go 1.27.1 重新嵌入 `bin/dev-build/antd/linkport.exe` 后,使用临时 SQLite 与隔离 18391/18392 端口运行 react-migration.spec.ts、ui-domain.spec.ts,13/13 通过(14.6 秒)。三个 Node domain 文件 16/16 通过;`go vet ./...` 通过。最终新程序完成 11 个菜单 × 浅深两种主题 × 1440/1024/390/320px 共 88 组检查,无 pageerror 或页面横向溢出,16 张截图在 `bin/dev-build/antd`。管理请求使用真实本机 API,阶段错误使用 HTTP 响应 fixture;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。恢复 `web/dist/.gitkeep`,未部署或修改真实 data。
`go test ./... -count=1 -timeout=120s` 未通过:API 的 TestWebDAVSyncSettingsAndAuthorization(同步口令未保留)、cluster 的 TestMySQLReplicationAndGroupDiscovery(fixture nil panic)、desktop 的路径相关用例、syncjob 的两个明文秘密断言失败,与既有记录一致;协议包均通过,不能视为真实上游服务器验证。日志见 `bin/dev-build/antd/go-test.log`。构建仍有 Node 22.10.0 低于 Vite 推荐版本及约 1.20 MB JS chunk 提示,构建成功。
完整 Playwright 套件发现 146 项,使用 `--max-failures=5` 执行后 5 项失败、141 项未运行,另有停止后的 suite 级错误提示。失败来自 account-editor(2 项)、account-targets(1 项)、asset-editor-dialog(2 项),仍依赖旧 Vue 的 `.target-card`、`.el-select`、`.connection-editor` 及“配置”“+ 新建目标”等旧文案;未迁移的旧用例不记为通过。日志在 `bin/dev-build/antd/playwright-suite.log`。最终 13 项当前 React 专项通过与完整旧套件结果分别记录,`git diff --check` 通过。
## UI 密度、小屏弹窗与筛选验收(2026-10-04)
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/ui-polish/linkport-final.exe,以 LINKPORT_E2E_BINARY、临时 SQLite 和 18451/18452 端口运行 React 迁移/domain Playwright,13/13 通过。额外隔离 18431 端口验收空资源创建、空搜索/范围结果清除及列偏好跨刷新保留;11 个菜单 × 浅深主题 × 1440/1024/390/320px 共 88 组检查无 pageerror 或页面横向溢出,8 组编辑器在滚动内容前后保存按钮均完全位于视口内,截图 16 张。浏览器使用本机真实管理 API 和隔离配置;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。日志与截图位于 bin/dev-build/ui-polish。
Go vet ./... 通过。全量 go test ./... -count=1 -timeout=120s 仍失败于 API 的同步口令保留、cluster 的 MySQL fixture nil panic、desktop 的数据路径用例与 syncjob 的两个明文秘密断言,与前轮记录一致;协议包通过但不代表真实服务器验证。首次 Go 与 Playwright 并发时,Go 目录扫描撞上 web/test-results 清理,报目录不存在;浏览器完成后重新执行 Go 检查,上述为重跑结果。
完整旧 Playwright 套件发现 146 项,使用 --max-failures=1 --timeout=15000 运行后,首项 account-editor 因依赖旧 .target-card / “配置目标”失败,145 项未运行,另有停止后的 suite 级错误提示;不能把专项通过视为旧套件全部通过。构建保留 Node 22.10.0 低于 Vite 推荐版本与约 1.22 MB JS chunk 提示。git diff --check 通过。
## 审计查询区集中验收(2026-10-05)
前端生产构建及 Go 1.27.1 新嵌入程序成功,恢复 web/dist/.gitkeep;LINKPORT_E2E_BINARY 指向 bin/dev-build/audit-query/linkport.exe,React 迁移/domain Playwright 13/13 通过(16.2 秒),使用临时 SQLite、18511/18512 端口。命令与事件审计 × 浅深主题 × 四种屏宽的 16 组检查通过,查询区统一包含范围、字段和动作,项目/环境/关键词请求参数正确,无 pageerror 或横向溢出;仅使用本机管理 API,没有真实协议目标。
补充全量检查:Go 1.27.1 下 go vet ./... 通过;go test ./... -count=1 -timeout=120s 未通过,失败为 API 同步口令保留、cluster MySQL fixture nil panic、desktop 路径用例、pgproxy/TestPreparedRejectsDifferentIdentity 以及 syncjob 两个明文秘密断言,完整细节在 bin/dev-build/audit-query/go-test.log。未将后端全量或协议 fixture 结果当作真实目标服务器验证。完整 Playwright 发现 146 项,使用 --max-failures=1 --timeout=15000 执行后,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示,不记为全套通过。构建保留 Node 22.10.0 版本和约 1.22 MB chunk 提示,仍成功。
## 底色与彩色文字按钮验收(2026-10-05)
生产构建与 Go 1.27.1 新嵌入 bin/dev-build/button-soft/linkport.exe 成功,恢复 web/dist/.gitkeep。隔离临时 SQLite、18611/18612 端口的 React 迁移/domain Playwright 13/13 通过(15.6 秒)。18621 端口的 GET 资源 fixture 验收覆盖浅深主题与 1440/390/320px,五类按钮使用 Ant Design filled variant 及对应语义色,背景非透明、文字和背景不同,无 pageerror 或横向溢出。视觉截图在 bin/dev-build/button-soft,没有执行真实网关启停或连接上游服务;git diff --check 通过。后端未改动,沿用本会话 audit-query 全量失败记录。
完整旧 Playwright 套件另发现 146 项,以新程序、18631/18632 端口及 --max-failures=1 --timeout=15000 执行,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示;不宣称全套通过,详见 bin/dev-build/button-soft/playwright-suite.log。
## 审计查询下拉回归(2026-10-05)
TypeScript / Vite 生产构建及 Go 1.27.1 新嵌入 bin/dev-build/audit-selects/linkport.exe 成功,恢复 web/dist/.gitkeep。以 LINKPORT_E2E_BINARY、临时 SQLite、18711/18712 端口执行 React 迁移/domain Playwright,14/14 通过(16.7 秒);新增回归使用 GET 资源 fixture,验证资源名称下拉、跨项目选项排除、项目/环境/网关 ID/目标 ID/协议/处理结果/关键词实际查询参数,以及重置清除审计条件并保留范围。18721 隔离验收两种审计页 × 两种主题 × 四种屏宽,共 16 组通过,查询字段与动作保持集中,项目/环境选择后与关键词一起提交,无 pageerror 或页面横向溢出;输出 16 张截图。日志和截图在 bin/dev-build/audit-selects,没有连接真实数据库或其他协议目标;git diff --check 通过。构建仍带 Node 22.10.0 版本及约 1.22 MB chunk 提示,成功生成产物。纯前端恢复不重复后端测试,本会话 audit-query 后端全量失败与 vet 通过记录仍保留。
完整旧 Playwright 套件发现 147 项,使用新程序、临时 SQLite、18731/18732 隔离端口及 --max-failures=1 --timeout=15000 执行;首项 account-editor 仍依赖旧 Vue .target-card / 配置目标而超时,1 项失败、146 项未运行,另有停止后的 suite 级错误提示,不宣称全套通过。日志见 bin/dev-build/audit-selects/playwright-suite.log。
2026-10-06 本轮收尾验证:新嵌入 bin/dev-build/cleanup/linkport.exe,17 项 Playwright 专项全部通过(26.4 秒),其中新增 11 页面导航/pageerror/横向溢出和协议字段测试;36 项 Node domain 测试通过。scripts/start-dev.test.ps1 通过,使用隔离 fixture 验证当前互斥锁、日志排除及原文件保护。Go 1.27.1 全量测试仅 internal/cluster 的既有 TestMySQLReplicationAndGroupDiscovery 发生 go-mysql fixture 身份协商 nil panic,其余包通过;go vet ./... 通过。新增旧配置包/模板版本拒绝测试通过。前端 TypeScript/Vite 构建成功,web/dist/.gitkeep 保留,仍有 Node 22.10.0 低于 Vite 推荐版本和大 JS chunk 提示。未做真实上游或生产部署验证。
完整 Playwright 本轮发现 150 项;max-failures=1、15 秒超时,首项 account-editor 仍依赖旧 Vue `.target-card` 而失败,149 项未运行,停止后另报 suite 错误。专项通过不等于旧全套通过。日志:bin/dev-build/cleanup/full-browser.log;当前专项:browser-final.log。git diff --check 通过。
## 全站表单反馈回归(2026-10-07)
执行 `scripts/build.ps1 -SkipTests -RebuildWeb -OutputPath bin/dev-build/cleanup/linkport.exe`,先完成 TypeScript/Vite,再编译 Go 1.27.1 嵌入程序;成功。构建仍提示 Node 22.10.0 低于 Vite 推荐版本及 JS chunk 较大,未阻止构建,web/dist/.gitkeep 保留。
使用 `LINKPORT_E2E_BINARY=C:\work\github\LinkPort\bin\dev-build\cleanup\linkport.exe`、18761/18762 端口,运行 form-feedback、outbound-fields、current-pages、react-migration、ui-domain、request-error、csrf 七份 Playwright 文件:31 项通过(32.6 秒)。覆盖空代理/资产、Kubernetes 缺项、目标账号页签定位、模板/备份校验、同步/解密/运维范围检查、目录服务端错误、420px 小屏、SSH 指纹确认、失败草稿保留以及隔离真实 API 的保存和密码保留。HTTP 拒绝与 SSH 指纹使用 fixtures,不代表真实 SSH、SOCKS5、Kubernetes 或数据库上游验收。
额外运行 `node --test web/tests/network-proxies.test.mjs web/tests/redis-accountless.test.mjs web/tests/persistent-target.test.mjs web/tests/tcp-kube-forward.test.mjs`:24 项通过,2 个旧测试文件加载失败,分别缺少已移除的 @vue/compiler-sfc 和 KubernetesFields.vue。保持旧用例和全套发现配置不变;没有宣称旧全套通过。
日志:`bin/dev-build/cleanup/forms-final-build.log`、`forms-final-browser.log`、`forms-domain.log`。本轮仅更改前端、测试和文档,没有重新运行后端全量测试;此前记录的 cluster MySQL fixture nil panic 仍属已知失败。没有重启真实服务、部署迁移或改动真实数据。`git diff --check` 通过。
## 后续交互排查与草稿测试(2026-10-07)
TypeScript/Vite 与 Go 1.27.1 新嵌入 `bin/dev-build/cleanup/linkport.exe` 构建通过。使用该程序、临时 SQLite 和 18771/18772 端口运行 draft-feedback、sync-feedback、form-feedback、outbound-fields、current-pages、react-migration、ui-domain、request-error、csrf:37 项通过(39.6 秒)。新增覆盖未保存目标的当前地址与所选账号、成功/失败诊断、SSH 指纹确认后重试、已有目标携带 ID 并省略已保存密码、协议选择禁用提示、WebDAV 草稿保护/并发控件/双重错误保留。`GOTOOLCHAIN=go1.27.1 go test ./internal/api -run '^TestTargetDraft' -count=1 -timeout=120s` 通过(包耗时 1.371 秒),使用现有协议 fixtures 验证草稿测试不持久化及凭据解析;未验证真实上游服务器。
首次同步回归两处定位器因全局和页面都有“刷新”按钮而失败,已限定到 WebDAV 页面。中间一次以 editor-test.spec.ts 筛选时同时匹配旧 target-editor-test.spec.ts:29 项通过、2 个旧 Vue UI 用例超时、21 项未运行及一次套件停止错误;旧用例依赖 dialog、.el-form-item、.target-card,仍保留且未更改全套发现规则。新回归文件改为 draft-feedback.spec.ts 避免命名碰撞,最终专项通过不表示旧全套通过。
日志:`bin/dev-build/cleanup/ui-followup-build.log`、`ui-followup-final-browser.log`、`ui-followup-draft-api.log`;中间失败记录保留于 `ui-followup-browser.log`。构建仍有 Node 版本和大 chunk 提示;`web/dist/.gitkeep` 保留,`git diff --check` 通过。本轮没有修改后端源码或执行真实部署,未重复全量 Go/vet;此前全量 cluster fixture 失败记录仍有效。
## 网关账号保存字段回归(2026-10-10)
使用 Go 1.27.1,定向执行 API/model 的 TestGatewayConfigurationFieldErrors、TestTCPIndependentBoundary、TestGatewayAccount 测试,通过。完整 `go test ./... -count=1 -timeout=120s` 中 API、model、store 及其他包通过,仅 internal/cluster 的既有 TestMySQLReplicationAndGroupDiscovery 发生 MySQL fixture nil panic。`go vet ./...` 最终通过;首次与 Playwright 并行时遇到 web/test-results 被清理导致的目录枚举错误,待浏览器完成后单独重跑通过。
TypeScript/Vite 与新嵌入 `bin/dev-build/cleanup/linkport.exe` 构建通过,web/dist/.gitkeep 保留;11 项 connection-draft Node 测试通过。以 LINKPORT_E2E_BINARY 指向该程序,18771/18772 隔离端口和临时 SQLite 执行 gateway-save-feedback、draft-feedback、sync-feedback、form-feedback、outbound-fields、current-pages、react-migration、ui-domain、request-error、csrf:39 项全部通过(42.3 秒)。新增场景通过真实隔离管理 API 完成既有 Redis 网关添加账号的 PUT、第二次编辑密码留空保留、目标新增账号;不会启动真实上游连接。
日志位于 `bin/dev-build/cleanup/gateway-fields-{targeted,go-test}.log`、`gateway-fields-final-{build,browser,domain,vet}.log`。后端错误回归验证非法字段路径与类型可见、密码值不回显、拒绝更新不修改配置。`git diff --check` 通过。未操作用户报错中的真实网关、部署迁移或宣称旧 Vue 全套通过。
+92 -1
View File
@@ -1,4 +1,95 @@
# DBX 风格界面
# Ant Design 默认界面
## 后续交互排查(2026-10-07)
修复编辑页连接测试与详情页不一致:目标直接测试当前草稿,结果复用详情诊断,明确成功/失败颜色并自动定位;已保存连接的协议下拉明确禁用,并解释需要新建连接,避免“可选择但无反应”。网关测试必须使用明确选择的有效账号,不静默替换。WebDAV 增加刷新/导航草稿保护、请求中的控件禁用、原始同步错误保留及正确的密码保留说明。详情连接测试成功也补充绿色 Alert。
仅调整当前 UI 和已有测试接口的接入,不迁移真实数据;使用新嵌入程序进行浏览器专项,以及现有目标草稿 Go API fixture 验证。实际结果与限制见 testing.md 的后续交互排查记录。
## 全站表单校验与保存反馈(2026-10-07)
原因:部分保存按钮在缺少名称、账号或文件时直接禁用,用户无法知道缺少什么;长页面和详情页的请求错误也可能出现在视口外。
共享 Field、Area、Pick 现在支持条件必填、Ant Design 错误状态、字段说明和 aria-required/aria-invalid/aria-describedby。useAction 配合 ValidationContext 提供错误汇总;提交校验失败时标红字段并聚焦第一处问题,必要时展开 details;修改该字段后清除其错误,不打断连续输入。服务端错误显示实际原因并滚动到当前可见页面;不会关闭编辑页或清空草稿。未知服务端错误不臆测归因到某个字段。
本轮覆盖代理/文件资产、目标/网关、WebDAV、备份/导入/模板、解密、监控/告警/Token、项目与环境目录及资源详情/画布关联入口。缺项导致的静默禁用改为点击后解释;忙碌、无变更、正在运行的引用保护及同步前保存要求仍保留禁用和相应说明。登录继续使用原生必填检查并显示必填文字,审计筛选保持可选。
目标/网关复用 domain 校验器,账号问题按稳定账号 ID 映射到当前输入字段,切换到相应页签和账号;汇总可点击定位。身份外观字段定位到外观页签。目标账号空密码、Redis 密码认证无需用户名、Kubernetes 无目标 host 以及已保存敏感值留空保留的规则不变。SSH 指纹不作为要求用户填写的缺项,仍自动探测并由用户确认后保存。
数值范围与服务端一致:健康检查为 0 或 10–86400 秒,日志上限 16777216–4294967296 字节,连接检查 1–3 次,WebDAV 间隔 1–10080 分钟;告警阈值/窗口/提醒天数及 Token 到期时间在提交前检查。Webhook 地址使用 URL 输入,不再按密码遮盖。备份口令、JSON 对象、代理类型、SOCKS5 凭据字节长度和 Kubernetes 资产/资源错误均有明确说明。
兼容影响:仅调整当前前端交互与前置校验,不改变 HTTP API、持久化格式或数据库迁移,不部署到真实数据目录。服务端仍为最终校验边界。
实际验证:TypeScript/Vite 与新嵌入程序构建成功;31 项 Playwright 专项通过(32.6 秒),含 420px 小屏、11 页面导航、字段级校验、跨账号页签定位、SSH 确认保存、服务端拒绝后保留草稿及隔离真实 API 的密码保留。另执行 Node domain 检查,24 项通过,两份旧测试因 Vue 依赖/文件已移除而加载失败;详见 testing.md。没有进行真实上游协议连通性或真实数据部署验收。
## 命令审计查询下拉恢复(2026-10-05)
原因:React 迁移把审计筛选统一渲染成 Field 文本框,丢失旧 Vue 的协议、网关、目标和处理状态选择器;用户要求恢复查询下拉。
结果与范围:增加原生 Ant Design QuerySelect(标签关联、中文可访问名称、按名称搜索、清空、全部选项、非虚拟选项),命令审计恢复网关、目标、协议及处理结果下拉。网关/目标显示名称、提交 ID,按当前项目/环境过滤并按名称及 ID 排序;处理结果使用 allowed/blocked/input 三种既有值,协议沿用实际服务类型值。操作类型仍支持任意协议命令输入,来源 IP、关键词与时间继续为原输入控件。项目/环境使用独立 AuditScopeFields 下拉,包含全部和未分配选项,并保留当前存储中的未列出范围值;范围状态仍由 App.tsx 协调。连接与审计同时复用项目/环境与事件服务下拉,事件服务覆盖同范围网关和目标。AuditFilters 负责独立筛选视图,AuditPage 继续处理查询/分页/详情。
兼容:所有条件仍在同一查询卡片中,保留上一步底色按钮与新 Logo;范围变化时仍重置审计条件,重置仅清除审计字段、保留工作区范围。HTTP 查询参数、CSV 导出、ID 值、权限、存储格式与数据库 schema 不变,无数据迁移,未部署或修改真实 data。网关/目标字段改为名称选择,不需手工输入 ID;其余辅助页和资源页的范围控件不变。
实际验证:TypeScript / Vite 生产构建及 Go 1.27.1 新嵌入 bin/dev-build/audit-selects/linkport.exe 成功,恢复 web/dist/.gitkeep。以 LINKPORT_E2E_BINARY、临时 SQLite、18711/18712 端口执行 React 迁移/domain Playwright,14/14 通过(16.7 秒);新增回归使用 GET 资源 fixture,验证资源名称下拉、跨项目选项排除、项目/环境/网关 ID/目标 ID/协议/处理结果/关键词实际查询参数,以及重置清除审计条件并保留范围。18721 隔离验收两种审计页 × 两种主题 × 四种屏宽,共 16 组通过,查询字段与动作保持集中,项目/环境选择后与关键词一起提交,无 pageerror 或页面横向溢出;输出 16 张截图。日志和截图在 bin/dev-build/audit-selects,没有连接真实数据库或其他协议目标;git diff --check 通过。构建仍带 Node 22.10.0 版本及约 1.22 MB chunk 提示,成功生成产物。纯前端恢复不重复后端测试,本会话 audit-query 后端全量失败与 vet 通过记录仍保留。
## 按钮统一使用底色与彩色文字(2026-10-05)
原因:用户截图中详情为描边、编辑为纯文字、启停为实心蓝色、删除为红色描边,要求全部使用背景底色并以不同颜色文字区分动作。
结果与范围:应用级 Button 统一映射为 Ant Design 原生 variant="filled",即浅底色及对应彩色文字,不添加 CSS 配色或自定义主题。保留旧页面 variant 接口用于默认色语义:原 contained 默认主色,其余默认中性色;显式 error/success/warning/info/secondary 分别映射 red/green/orange/cyan/purple。资源列表详情蓝色、编辑青色、启动绿色、停止橙色、删除红色;批量启停及画布编辑/启停使用相同语义。导航与列设置改为复用统一按钮,选中导航为主色底色、普通导航为中性底色,图标按钮也使用底色。禁用、大小、事件和表单提交行为沿用框架;fullWidth 显式映射到宽度,避免传给 DOM。
兼容:只改变应用操作按钮的外观和语义色,保持 Ant Design 默认浅深算法、原中文名称和请求/确认行为,无接口、数据库或偏好迁移。保留本会话查询条件合并和新 Logo;未部署或修改真实 data。系统内部的关闭、分页箭头等框架控件仍由 Ant Design 管理。
实际验证:TypeScript / Vite 生产构建与 Go 1.27.1 新嵌入 bin/dev-build/button-soft/linkport.exe 成功,恢复 web/dist/.gitkeep;LINKPORT_E2E_BINARY 指向该程序,使用临时 SQLite、18611/18612 端口执行 React 迁移/domain Playwright,13/13 通过(15.6 秒)。另以隔离 18621 端口和 GET 资源响应 fixture 检查浅深主题、1440/390/320px:详情/编辑/启动/停止/删除均使用 filled 和对应语义色,计算样式背景非透明、文字与背景不同,无 pageerror 或页面横向溢出;导出界面及动作区域截图,视觉复核通过。fixture 验收没有执行网关启停或连接真实目标。日志及截图在 bin/dev-build/button-soft,git diff --check 通过。构建仍有 Node 22.10.0 版本与约 1.22 MB chunk 提示,成功生成产物。纯前端外观更新不重复后端测试,沿用本会话 audit-query 全量后端失败与 vet 通过记录,不记为后端全量通过。
完整旧 Playwright 套件另发现 146 项,以新程序、18631/18632 端口及 --max-failures=1 --timeout=15000 执行,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示;不宣称全套通过,详见 bin/dev-build/button-soft/playwright-suite.log。
## 审计查询条件集中(2026-10-05)
原因:用户截图中的命令审计把项目/环境放在标题上方,审计字段放在标题下方,查询需要在两处操作。
结果与范围:命令审计和连接与审计取消独立的顶部范围栏,将项目、环境与网关、协议、目标、来源、类型、关键词、起止时间等页面适用条件放进标题下同一张查询卡片,使用与资源页一致的响应式字段网格。筛选、重置、导出 CSV、清除范围、管理项目与环境、刷新均集中到卡片内动作栏,筛选为主按钮。移除查询卡片内重复的审计标题,保留页面主标题。App.tsx 仍协调共享范围,AuxiliaryPage 仅传递视图,AuditPage 负责审计查询。
兼容:只调整审计页布局与按钮主次,不修改查询参数、范围变化时重置审计条件、CSV 导出、权限、偏好键或后端协议;无接口/数据库迁移,未部署或修改真实 data。其他辅助页的范围栏及资源页保持原布局。更新已有浏览器用例对事件页面主标题的定位。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/audit-query/linkport.exe,使用 LINKPORT_E2E_BINARY、临时 SQLite 和 18511/18512 端口运行 React 迁移/domain Playwright,13/13 通过(16.2 秒)。隔离 18521 端口另检查两种审计页 × 浅深主题 × 1440/1024/390/320px,共 16 组通过:每页仅一个查询区,项目/环境及全部适用字段和动作均在其中,无独立范围栏、页面横向溢出或 pageerror。真实本机 API 请求确认项目、环境和关键词一起提交。截图和日志位于 bin/dev-build/audit-query;没有连接真实数据库、SSH 或其他协议目标。git diff --check 通过。
补充全量检查:Go 1.27.1 下 go vet ./... 通过;go test ./... -count=1 -timeout=120s 未通过,失败为 API 同步口令保留、cluster MySQL fixture nil panic、desktop 路径用例、pgproxy/TestPreparedRejectsDifferentIdentity 以及 syncjob 两个明文秘密断言,完整细节在 bin/dev-build/audit-query/go-test.log。未将后端全量或协议 fixture 结果当作真实目标服务器验证。完整 Playwright 发现 146 项,使用 --max-failures=1 --timeout=15000 执行后,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示,不记为全套通过。构建保留 Node 22.10.0 版本和约 1.22 MB chunk 提示,仍成功。
## 界面密度与小屏编辑优化(2026-10-04)
原因:Ant Design 迁移后,查询卡片同时叠加应用 padding 与框架 padding,列设置独占整张卡片,手机编辑器整体高度超过视口,保存与取消需要长距离滚动;导航符号及主题标签也占据重复空间。
结果:查询区删除重复 padding,资源关键词前置,统一收紧字段、页面与标题间距;标题说明使用 Ant Design secondary 文本。列设置改为列表工具栏的 Popover,增加当前筛选结果总数,仍复用原列偏好键。导航使用统一 Ant Design 图标与 Tooltip,保留中文可访问名称、资源计数与小屏图标栏。主题下拉隐藏视觉标签但保留关联 label 和“主题”可访问名称。无资源时提供创建入口,已有资源但筛选为空时提供“清除筛选”,同时清空资源关键词、协议、状态及工作区项目/环境,保留分组和列偏好。
编辑与确认弹窗限制在当前视口内:标题和底部动作固定在弹窗布局中,内容区独立滚动,窄屏底部账号选择独占一行,保存/测试/取消始终可达。使用 Ant Design 默认主题与控件外观,CSS 只调整布局、滚动和视觉隐藏标签。新导航与列设置视图分别位于 WorkspaceNavigation.tsx、resources/ResourceColumns.tsx;App.tsx 继续协调范围状态。
范围与兼容:影响工作区导航、主题工具栏、资源查询/列表及共享弹窗;清除筛选会更新既有非敏感筛选偏好,无新增存储格式、HTTP API 或 SQLite schema 变化,不需数据迁移。凭据省略、未保存确认和请求权限不变;未部署、重启真实服务或修改真实 data。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/ui-polish/linkport-final.exe,以 LINKPORT_E2E_BINARY、临时 SQLite 和 18451/18452 端口运行 React 迁移/domain Playwright,13/13 通过。额外隔离 18431 端口验收空资源创建、空搜索/范围结果清除及列偏好跨刷新保留;11 个菜单 × 浅深主题 × 1440/1024/390/320px 共 88 组检查无 pageerror 或页面横向溢出,8 组编辑器在滚动内容前后保存按钮均完全位于视口内,截图 16 张。浏览器使用本机真实管理 API 和隔离配置;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。日志与截图位于 bin/dev-build/ui-polish。
Go vet ./... 通过。全量 go test ./... -count=1 -timeout=120s 仍失败于 API 的同步口令保留、cluster 的 MySQL fixture nil panic、desktop 的数据路径用例与 syncjob 的两个明文秘密断言,与前轮记录一致;协议包通过但不代表真实服务器验证。首次 Go 与 Playwright 并发时,Go 目录扫描撞上 web/test-results 清理,报目录不存在;浏览器完成后重新执行 Go 检查,上述为重跑结果。
完整旧 Playwright 套件发现 146 项,使用 --max-failures=1 --timeout=15000 运行后,首项 account-editor 因依赖旧 .target-card / “配置目标”失败,145 项未运行,另有停止后的 suite 级错误提示;不能把专项通过视为旧套件全部通过。构建保留 Node 22.10.0 低于 Vite 推荐版本与约 1.22 MB JS chunk 提示。git diff --check 通过。
## 当前 React + Ant Design(2026-10-04)
按用户最新要求,生产前端使用 React 19 与 Ant Design 6,删除 MUI / Emotion 依赖。`App.tsx` 通过 `ConfigProvider` 选择默认浅色或深色算法并提供中文语言包,组件外观来自 Ant Design。`web/src/react/components.tsx` 将现有页面接口映射到 Input、Select、Switch、Checkbox、Tabs、Modal、Collapse、Card、Table、Alert、Tag 与 Typography,避免组件库替换影响领域逻辑;应用 CSS 仅管理布局。接口、SQLite schema、主题偏好键及秘密省略规则不变,无数据迁移。当前验证见 [React 迁移](react-migration.md);下面 MUI / HeroUI 记录为历史,不代表当前版本验收。
2026-10-04:按用户最新要求,当前 React 前端采用原生 MUI 9(Material UI)与 Emotion,HeroUI 已移除。`ThemeProvider` 和 `CssBaseline` 提供默认浅色/深色外观,`createTheme` 只设置 `palette.mode`;按钮、TextField、Select、Checkbox、Switch、Tabs、Dialog、Card、Table、Alert、Chip 与 Typography 直接使用 MUI API。没有自定义主题色、组件圆角、阴影或 CSS 外观覆盖,也没有保留 HeroUI compound 兼容壳。应用 CSS 只负责布局、间距、滚动和断点。
工作区继续保留集中查询条件、宽松留白、浅色/深色/跟随系统及既有非敏感偏好。此轮无 API、SQLite schema 或真实 data 迁移。当前实现、兼容边界及实际验收见 [React 迁移](react-migration.md) 和 [测试记录](testing.md)。下文保留 HeroUI 和更早 Vue / DBX 的历史设计,不描述当前生产外观。
当前 MUI 生产构建及新嵌入 `bin/dev-build/mui/linkport.exe` 的 13 项迁移/domain Playwright 完整复测通过;首次 DialogTitle 重复 id 的可访问名称问题修正后重建并复测。1440、1024、390、320px 无横向溢出或 pageerror,浅深、移动与编辑器截图位于 `bin/dev-build/mui`。这是界面与真实管理 API 的隔离验收,没有真实远端协议服务器测试。
## HeroUI 阶段(历史,2026-10-04)
2026-10-04:当前前端已改用 React 与 HeroUI 默认组件样式,旧 DBX 外观、Vue / Element Plus 和自定义主题 CSS 已移除。仅保留工作区布局 CSS。下文是历史设计记录,不描述当前生产外观;当前职责、兼容变化和验证见 [React 迁移](react-migration.md)。
## 功能重要性与状态配色(2026-09-24)
+11 -9
View File
@@ -4,18 +4,20 @@
## 连接设置
2026-10-07 UI 修复:编辑未保存时,刷新设置或离开当前页面须确认,取消保留草稿;请求进行中禁用刷新、密码清除和自动同步开关,避免请求结果覆盖中途修改。同步失败与随后状态刷新失败同时发生时保留原始同步错误;同步成功但状态刷新失败时明确说明操作已完成,不提示重复同步。密码提示与服务端保持一致:仅地址和用户名都未改变时留空保留旧密码。这些改动不改变 WebDAV API 或存储格式。新增浏览器 fixtures 验证上述行为,不连接真实 WebDAV 服务。
- WebDAV 地址填写已有基础目录的完整 HTTP(S) 地址,例如 `https://dav.example.com/remote.php/dav/files/user/`,而不是仅填写网站首页。基础目录必须存在;不要包含用户名密码、查询参数或 URL 片段。
- 目录前缀默认为 `LinkPort/`;留空保存时也会使用该默认值。允许相对多级目录,不允许绝对路径、`..`、反斜杠或连续斜杠。上传时通过 MKCOL 创建前缀下需要的子目录,不创建基础目录。
- 支持 Basic 用户名/密码(包括服务商应用密码);匿名目录将用户名和密码均留空。建议 HTTPS;HTTP 的登录信息可能被窃听。不支持自动跳转、Digest、OAuth 或自签名证书跳过校验。
- 编辑已保存设置时,登录密码留空且地址、用户名均未改变才保留旧密码;更改地址或用户名后需要重新填写。勾选清除密码可显式移除旧密码。
- **加密口令不是 WebDAV 登录密码**。口令为 12–1024 字节;所有同步设备使用相同口令。保存口令留空保留原值;手动操作可填写仅本次使用的口令,自动同步必须保存口令。更换口令应使用新的前缀,并在所有设备重新配置。
- 当前同步不使用加密口令。2026-10-06 删除无效 passphrase 兼容字段,提交旧参数返回 400。同步和独立备份都是包含凭据的版本 2 JSON 配置包,应使用 HTTPS 与受控远端目录。
- 自动同步默认关闭,开启后默认每 5 分钟执行,允许 1–10080 分钟。服务停止期间不执行,重启不补跑全部错过的周期。手动同步或备份会更新最近执行时间,下次自动同步从该时间重新计算。
登录密码与保存的加密口令在本机加密存储,接口仅返回是否已保存,不回传秘密。上传前使用 AES-256-GCM 加密,PBKDF2-SHA256(600000 次)派生密钥,每次使用随机盐和 nonce。明文配置需小于 16 MiB(预留加密认证标签空间),远端加密文件上限 24 MiB。
WebDAV 登录密码在本机加密保存,接口只返回 hasPassword,不回传密码。配置包内的业务凭据用于恢复/同步,远端 JSON 包含这些凭据;这与配置管理的手动加密导出是不同入口。
## 多端同步
各设备使用相同基础地址、目录前缀和加密口令;共享文件为 `<prefix>/sync/configuration.json`。
各设备使用相同基础地址和目录前缀,并具备读写权限;共享文件为 `<prefix>/sync/configuration.json`。
同步范围包含目标、代理、资产文件及凭据、项目/环境选项、网关监听配置、访问控制和账号映射,新增、修改、删除均会传播。审计日志、个人登录凭据、API Token、配置历史、设备 SSH 身份、WebDAV 设置与网关运行状态保留在设备本地;外部文件路径不会自动转成文件资产。
@@ -29,7 +31,7 @@
同步配置替换和基线写入沿用同一个短数据库事务,确保本地内容与基线一致;设置更新与基线清除也保持原子性。网络传输不放入数据库事务,任务通过设备锁和数据库租约避免重入。
具体比较规范化后配置的 SHA-256,而不是含随机盐的密文,也不使用设备时间决定哪端胜出。记本机哈希为 L、远端为 R、本机上次同步基线为 B;已有基线且远端文件存在时:
具体比较规范化后配置的 SHA-256,不使用设备时间决定哪端胜出。记本机哈希为 L、远端为 R、本机上次同步基线为 B;已有基线且远端文件存在时:
| 条件 | 动作 |
| --- | --- |
@@ -42,7 +44,7 @@
## 独立加密备份与恢复
「立即备份本机」在 `<prefix>/backups/<UTC时间戳>-<随机值>.json` 创建独立加密文件,不读取或修改共享同步文件,不替换本机配置,不推进同步基线,也不消除已有冲突。每次备份使用不同文件名和条件创建,避免覆盖旧备份。其内容范围与同步快照一致;它不是包含主密钥、审计的完整 SQLite 灾备。
「立即备份本机」在 `<prefix>/backups/<UTC时间戳>-<随机值>.json` 创建独立 JSON 配置文件,不读取或修改共享同步文件,不替换本机配置,不推进同步基线,也不消除已有冲突。每次备份使用不同文件名和条件创建,避免覆盖旧备份。其内容范围与同步快照一致;它不是包含主密钥、审计的完整 SQLite 灾备。
页面最近执行时间、成功/错误和动作记录当前由同步与备份共用;成功备份会更新最近成功并清除上次任务错误,但不代表同步冲突已解决或远端同步成功,冲突标志仍保留。
@@ -56,9 +58,9 @@
## 从 S3 升级
WebDAV 替代原 S3 页面、调度任务和 API,旧 `/api/v1/configuration/s3/*` 路由返回 404。旧 S3 设置、状态表和远端文件保留,不删除、不自动迁移或继续上传;WebDAV 独立初始化设置及同步基线,自动同步默认关闭。
WebDAV 替代原 S3 页面、调度任务和 API,旧 `/api/v1/configuration/s3/*` 路由返回 404。S3 设置存储已删除,不再创建、读取或校验旧 S3 设置;WebDAV 独立初始化设置及同步基线,自动同步默认关闭。真实数据库与远端文件不在本次代码清理中修改。
部署新版本前先停止自己管理的本地服务,备份完整 `data` 目录。首次运行新版本会新增 `webdav_sync_settings` / `webdav_sync_state` 表,不覆盖旧 S3 数据。随后填写 WebDAV 地址与凭据,建议使用空的新前缀,并先手动备份或同步确认。旧版回退可能重新使用旧 S3 自动设置,回退前需检查旧配置,不能假定 WebDAV 已迁移回 S3。
当前使用 schema 3 数据库及版本 2 配置包;保留旧数据库的本地启动迁移,远端版本 1 配置包仍不直接导入,两者不属于同一种迁移。新环境请使用新的数据路径与远端前缀。S3 设置存储删除已获用户明确确认,不会自动转为 WebDAV 设置;详细影响见 [S3 设置存储](s3-sync.md)。
## API
@@ -71,9 +73,9 @@ WebDAV 替代原 S3 页面、调度任务和 API,旧 `/api/v1/configuration/s3
| POST | `/api/v1/configuration/webdav/sync` | 手动同步 |
| POST | `/api/v1/configuration/webdav/backup` | 独立备份本机 |
设置字段为 `endpoint`、`prefix`、`username`、`password`、`clearPassword`、`passphrase`、`automatic`、`intervalMinutes`。只读秘密状态为 `hasPassword`、`hasPassphrase`。
设置字段为 `endpoint`、`prefix`、`username`、`password`、`clearPassword`、`automatic`、`intervalMinutes`。只读秘密状态为 `hasPassword`。
同步与备份请求可含 `passphrase`,留空使用保存值;无额外参数时仍发送 `{}`。处理同步冲突时传 `resolution: "local" / "remote"` 及状态接口返回的 `localHash`、`remoteHash`,独立备份不接受冲突选择。成功结果包含 `endpoint`、`key` 和 `action`:同步为 `pushed` / `pulled` / `unchanged`,备份为 `backed_up`。
同步与备份请求不接受 `passphrase`;无额外参数时发送 `{}`。处理同步冲突时传 `resolution: "local" / "remote"` 及状态接口返回的 `localHash`、`remoteHash`,独立备份不接受冲突选择。成功结果包含 `endpoint`、`key` 和 `action`:同步为 `pushed` / `pulled` / `unchanged`,备份为 `backed_up`。
## 验证边界
+113
View File
@@ -1,5 +1,108 @@
# 工作区导航与交互
## 添加账号后的保存失败修复(2026-10-10)
原因:编辑器的“添加账号”共用一个对象,给网关账号混入目标专用的 remark/authMethod,也给目标账号混入网关专用的 allowCIDRs/authorizedKeys/disabled/accessMode。后端拒绝这些字段,但网关 PUT 只返回 invalid configuration fields。
现在新增目标/网关账号分别构造对应结构;提交保存与草稿测试时,每种账号仅发送其当前 API 支持的字段,保持账号 ID、目标映射、有效期、密钥资产以及空密码保留规则。最大连接数前置校验要求整数。网关创建、更新、草稿测试遇到非法配置字段时显示具体路径,例如 gatewayAccounts[0].authMethod,并说明不支持、不能为 null 或类型错误;错误不包含字段值。
此修复不放宽后端未知字段校验,不变更数据库格式或迁移,不修改真实网关。新增隔离 API 浏览器回归覆盖编辑网关添加账号、再次编辑密码留空保留、目标新增账号;后端拒绝请求不得修改已有配置且不得泄漏凭据。实际构建与测试结果见 testing.md 的 2026-10-10 记录。
## 业务弹框改为工作区页面(2026-10-07)
新建/编辑目标、网关、代理和资产,以及连接详情、代理测试结果、影响范围、审计详情、Token 展示、监控详情、配置导入预览/差异、项目环境管理和关联目标表单,改为主工作区中的独立页面。列表暂时隐藏并保留状态,页面提供明确标题、返回入口和底部保存操作。返回后恢复列表及滚动位置,不再通过关闭遮罩或 Escape 退出业务视图。删除、断开关联、放弃未保存内容继续使用简短确认。
代理/资产保存和校验错误显示在当前页面的底部操作区,服务端报错也不会落到隐藏的列表;返回或再次新建时清除旧错误。页面导航与刷新保留未保存提醒,取消切换继续保留草稿。目标/网关已有的未保存确认与敏感信息保护继续保留。业务页面属于当前工作区视图,不把草稿、凭据或详情写入 URL/localStorage。
代理认证方式改为下拉:SOCKS5 支持无需认证/用户名密码,SSH 仅支持用户名密码,切换到 SSH 时选择其有效认证方式。无需认证时隐藏用户名和密码;保存时校验缺失凭据,编辑已有密码允许留空保留。Kubeconfig 资产改为按名称选择、提交 ID;新建/编辑代理页面标题不再误称资产。
前端行为变更,无 HTTP API、数据库格式或迁移变化;没有部署或更改真实数据。验证覆盖当前页面、保存、导航草稿保护、认证下拉和错误显示,实际记录见 [测试记录](testing.md)。
## 命令审计查询下拉恢复(2026-10-05)
原因:React 迁移把审计筛选统一渲染成 Field 文本框,丢失旧 Vue 的协议、网关、目标和处理状态选择器;用户要求恢复查询下拉。
结果与范围:增加原生 Ant Design QuerySelect(标签关联、中文可访问名称、按名称搜索、清空、全部选项、非虚拟选项),命令审计恢复网关、目标、协议及处理结果下拉。网关/目标显示名称、提交 ID,按当前项目/环境过滤并按名称及 ID 排序;处理结果使用 allowed/blocked/input 三种既有值,协议沿用实际服务类型值。操作类型仍支持任意协议命令输入,来源 IP、关键词与时间继续为原输入控件。项目/环境使用独立 AuditScopeFields 下拉,包含全部和未分配选项,并保留当前存储中的未列出范围值;范围状态仍由 App.tsx 协调。连接与审计同时复用项目/环境与事件服务下拉,事件服务覆盖同范围网关和目标。AuditFilters 负责独立筛选视图,AuditPage 继续处理查询/分页/详情。
兼容:所有条件仍在同一查询卡片中,保留上一步底色按钮与新 Logo;范围变化时仍重置审计条件,重置仅清除审计字段、保留工作区范围。HTTP 查询参数、CSV 导出、ID 值、权限、存储格式与数据库 schema 不变,无数据迁移,未部署或修改真实 data。网关/目标字段改为名称选择,不需手工输入 ID;其余辅助页和资源页的范围控件不变。
实际验证:TypeScript / Vite 生产构建及 Go 1.27.1 新嵌入 bin/dev-build/audit-selects/linkport.exe 成功,恢复 web/dist/.gitkeep。以 LINKPORT_E2E_BINARY、临时 SQLite、18711/18712 端口执行 React 迁移/domain Playwright,14/14 通过(16.7 秒);新增回归使用 GET 资源 fixture,验证资源名称下拉、跨项目选项排除、项目/环境/网关 ID/目标 ID/协议/处理结果/关键词实际查询参数,以及重置清除审计条件并保留范围。18721 隔离验收两种审计页 × 两种主题 × 四种屏宽,共 16 组通过,查询字段与动作保持集中,项目/环境选择后与关键词一起提交,无 pageerror 或页面横向溢出;输出 16 张截图。日志和截图在 bin/dev-build/audit-selects,没有连接真实数据库或其他协议目标;git diff --check 通过。构建仍带 Node 22.10.0 版本及约 1.22 MB chunk 提示,成功生成产物。纯前端恢复不重复后端测试,本会话 audit-query 后端全量失败与 vet 通过记录仍保留。
## 按钮统一使用底色与彩色文字(2026-10-05)
原因:用户截图中详情为描边、编辑为纯文字、启停为实心蓝色、删除为红色描边,要求全部使用背景底色并以不同颜色文字区分动作。
结果与范围:应用级 Button 统一映射为 Ant Design 原生 variant="filled",即浅底色及对应彩色文字,不添加 CSS 配色或自定义主题。保留旧页面 variant 接口用于默认色语义:原 contained 默认主色,其余默认中性色;显式 error/success/warning/info/secondary 分别映射 red/green/orange/cyan/purple。资源列表详情蓝色、编辑青色、启动绿色、停止橙色、删除红色;批量启停及画布编辑/启停使用相同语义。导航与列设置改为复用统一按钮,选中导航为主色底色、普通导航为中性底色,图标按钮也使用底色。禁用、大小、事件和表单提交行为沿用框架;fullWidth 显式映射到宽度,避免传给 DOM。
兼容:只改变应用操作按钮的外观和语义色,保持 Ant Design 默认浅深算法、原中文名称和请求/确认行为,无接口、数据库或偏好迁移。保留本会话查询条件合并和新 Logo;未部署或修改真实 data。系统内部的关闭、分页箭头等框架控件仍由 Ant Design 管理。
实际验证:TypeScript / Vite 生产构建与 Go 1.27.1 新嵌入 bin/dev-build/button-soft/linkport.exe 成功,恢复 web/dist/.gitkeep;LINKPORT_E2E_BINARY 指向该程序,使用临时 SQLite、18611/18612 端口执行 React 迁移/domain Playwright,13/13 通过(15.6 秒)。另以隔离 18621 端口和 GET 资源响应 fixture 检查浅深主题、1440/390/320px:详情/编辑/启动/停止/删除均使用 filled 和对应语义色,计算样式背景非透明、文字与背景不同,无 pageerror 或页面横向溢出;导出界面及动作区域截图,视觉复核通过。fixture 验收没有执行网关启停或连接真实目标。日志及截图在 bin/dev-build/button-soft,git diff --check 通过。构建仍有 Node 22.10.0 版本与约 1.22 MB chunk 提示,成功生成产物。纯前端外观更新不重复后端测试,沿用本会话 audit-query 全量后端失败与 vet 通过记录,不记为后端全量通过。
完整旧 Playwright 套件另发现 146 项,以新程序、18631/18632 端口及 --max-failures=1 --timeout=15000 执行,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示;不宣称全套通过,详见 bin/dev-build/button-soft/playwright-suite.log。
## 审计查询条件集中(2026-10-05)
原因:用户截图中的命令审计把项目/环境放在标题上方,审计字段放在标题下方,查询需要在两处操作。
结果与范围:命令审计和连接与审计取消独立的顶部范围栏,将项目、环境与网关、协议、目标、来源、类型、关键词、起止时间等页面适用条件放进标题下同一张查询卡片,使用与资源页一致的响应式字段网格。筛选、重置、导出 CSV、清除范围、管理项目与环境、刷新均集中到卡片内动作栏,筛选为主按钮。移除查询卡片内重复的审计标题,保留页面主标题。App.tsx 仍协调共享范围,AuxiliaryPage 仅传递视图,AuditPage 负责审计查询。
兼容:只调整审计页布局与按钮主次,不修改查询参数、范围变化时重置审计条件、CSV 导出、权限、偏好键或后端协议;无接口/数据库迁移,未部署或修改真实 data。其他辅助页的范围栏及资源页保持原布局。更新已有浏览器用例对事件页面主标题的定位。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/audit-query/linkport.exe,使用 LINKPORT_E2E_BINARY、临时 SQLite 和 18511/18512 端口运行 React 迁移/domain Playwright,13/13 通过(16.2 秒)。隔离 18521 端口另检查两种审计页 × 浅深主题 × 1440/1024/390/320px,共 16 组通过:每页仅一个查询区,项目/环境及全部适用字段和动作均在其中,无独立范围栏、页面横向溢出或 pageerror。真实本机 API 请求确认项目、环境和关键词一起提交。截图和日志位于 bin/dev-build/audit-query;没有连接真实数据库、SSH 或其他协议目标。git diff --check 通过。
补充全量检查:Go 1.27.1 下 go vet ./... 通过;go test ./... -count=1 -timeout=120s 未通过,失败为 API 同步口令保留、cluster MySQL fixture nil panic、desktop 路径用例、pgproxy/TestPreparedRejectsDifferentIdentity 以及 syncjob 两个明文秘密断言,完整细节在 bin/dev-build/audit-query/go-test.log。未将后端全量或协议 fixture 结果当作真实目标服务器验证。完整 Playwright 发现 146 项,使用 --max-failures=1 --timeout=15000 执行后,首项 account-editor 仍依赖旧 Vue .target-card / “配置目标”而超时,145 项未运行,另有停止后的 suite 级错误提示,不记为全套通过。构建保留 Node 22.10.0 版本和约 1.22 MB chunk 提示,仍成功。
## 界面密度与小屏编辑优化(2026-10-04)
原因:Ant Design 迁移后,查询卡片同时叠加应用 padding 与框架 padding,列设置独占整张卡片,手机编辑器整体高度超过视口,保存与取消需要长距离滚动;导航符号及主题标签也占据重复空间。
结果:查询区删除重复 padding,资源关键词前置,统一收紧字段、页面与标题间距;标题说明使用 Ant Design secondary 文本。列设置改为列表工具栏的 Popover,增加当前筛选结果总数,仍复用原列偏好键。导航使用统一 Ant Design 图标与 Tooltip,保留中文可访问名称、资源计数与小屏图标栏。主题下拉隐藏视觉标签但保留关联 label 和“主题”可访问名称。无资源时提供创建入口,已有资源但筛选为空时提供“清除筛选”,同时清空资源关键词、协议、状态及工作区项目/环境,保留分组和列偏好。
编辑与确认弹窗限制在当前视口内:标题和底部动作固定在弹窗布局中,内容区独立滚动,窄屏底部账号选择独占一行,保存/测试/取消始终可达。使用 Ant Design 默认主题与控件外观,CSS 只调整布局、滚动和视觉隐藏标签。新导航与列设置视图分别位于 WorkspaceNavigation.tsx、resources/ResourceColumns.tsx;App.tsx 继续协调范围状态。
范围与兼容:影响工作区导航、主题工具栏、资源查询/列表及共享弹窗;清除筛选会更新既有非敏感筛选偏好,无新增存储格式、HTTP API 或 SQLite schema 变化,不需数据迁移。凭据省略、未保存确认和请求权限不变;未部署、重启真实服务或修改真实 data。
实际验证:TypeScript / Vite 生产构建通过,恢复 web/dist/.gitkeep;Go 1.27.1 新嵌入 bin/dev-build/ui-polish/linkport-final.exe,以 LINKPORT_E2E_BINARY、临时 SQLite 和 18451/18452 端口运行 React 迁移/domain Playwright,13/13 通过。额外隔离 18431 端口验收空资源创建、空搜索/范围结果清除及列偏好跨刷新保留;11 个菜单 × 浅深主题 × 1440/1024/390/320px 共 88 组检查无 pageerror 或页面横向溢出,8 组编辑器在滚动内容前后保存按钮均完全位于视口内,截图 16 张。浏览器使用本机真实管理 API 和隔离配置;没有连接真实数据库、SSH、Kubernetes 或 WebDAV 目标。日志与截图位于 bin/dev-build/ui-polish。
Go vet ./... 通过。全量 go test ./... -count=1 -timeout=120s 仍失败于 API 的同步口令保留、cluster 的 MySQL fixture nil panic、desktop 的数据路径用例与 syncjob 的两个明文秘密断言,与前轮记录一致;协议包通过但不代表真实服务器验证。首次 Go 与 Playwright 并发时,Go 目录扫描撞上 web/test-results 清理,报目录不存在;浏览器完成后重新执行 Go 检查,上述为重跑结果。
完整旧 Playwright 套件发现 146 项,使用 --max-failures=1 --timeout=15000 运行后,首项 account-editor 因依赖旧 .target-card / “配置目标”失败,145 项未运行,另有停止后的 suite 级错误提示;不能把专项通过视为旧套件全部通过。构建保留 Node 22.10.0 低于 Vite 推荐版本与约 1.22 MB JS chunk 提示。git diff --check 通过。
## 当前 React + Ant Design(2026-10-04)
按用户最新要求,生产前端使用 React 19 与 Ant Design 6,删除 MUI / Emotion 依赖。`App.tsx` 通过 `ConfigProvider` 选择默认浅色或深色算法并提供中文语言包,组件外观来自 Ant Design。`web/src/react/components.tsx` 将现有页面接口映射到 Input、Select、Switch、Checkbox、Tabs、Modal、Collapse、Card、Table、Alert、Tag 与 Typography,避免组件库替换影响领域逻辑;应用 CSS 仅管理布局。接口、SQLite schema、主题偏好键及秘密省略规则不变,无数据迁移。当前验证见 [React 迁移](react-migration.md);下面 MUI / HeroUI 记录为历史,不代表当前版本验收。
## 历史 MUI 工作区(2026-10-04)
按用户“使用 MUI”的最新要求,React 工作区已改为原生 MUI 默认主题。导航与动作使用 `Button`,标签使用 `Tabs` / `Tab`,标题使用 `Typography`,筛选使用 `TextField select` / `MenuItem`,集中查询使用 `Card` / `CardContent`,编辑与确认使用 `Dialog`;资源和审计表格使用 MUI `Table` 系列。浅深主题由 `ThemeProvider` / `CssBaseline` 提供,保持原主题存储键和跟随系统行为。
项目/环境与搜索、协议、运行状态、分组仍集中在标题下的同一个查询区,范围操作位于底部。宽屏三列、平板两列、手机单列及前轮增加的页面和内容间距继续作为布局保留;MUI 控件的颜色、字体、阴影、圆角和状态外观由默认框架提供。查询逻辑、范围共享、账号映射、未保存保护、秘密省略和配置版本冲突保护没有更改,无 API、SQLite schema 或真实 data 迁移。
当前 MUI 生产构建与新嵌入 `bin/dev-build/mui/linkport.exe` 的 13 项迁移/domain Playwright 完整复测通过(隔离 18241/18242、临时 SQLite,11.5 秒),16 项 Node domain 回归通过。首次 DialogTitle 重复 id 导致的名称问题修正后重新构建复测。额外 1440、1024、390、320px 检查无横向溢出或 pageerror;截图位于 `bin/dev-build/mui`,完整记录见 [测试记录](testing.md)。以下 HeroUI 布局验收与 Vue 工作区规则保留为历史记录,不把历史截图或测试通过结果作为当前 MUI 界面验证。
## 留白与阅读层次(历史,2026-10-04)
按“样式效果不够有呼吸感”的反馈调整工作区几何布局:桌面内容留白为上下 36/48px、左右 40px,资源内容最大宽度 1440px;页标题采用框架排版类,标题与说明、筛选面板及下一块内容保留明确间隔。侧栏菜单间距从 4px 增至 8px,表单与纵向内容间距增至 24px。
集中查询区在宽屏采用三列两行,平板两列、手机单列,避免六项字段挤在一排或不均匀地剩下一项。默认 HeroUI Card 使用框架 `p-5 md:p-8` 间距,查询字段间距 24px,范围操作与字段分开留白。继续使用框架默认配色、阴影、圆角与控件外观;无筛选逻辑、API 或数据迁移。
生产构建及 Go 1.27.1 新嵌入 `bin/dev-build/airy-layout/linkport.exe` 通过。隔离 SQLite / 18231、18232 的 13 项迁移/domain Playwright 通过(12.1秒);额外浏览器验证 1440px 三列、1024px 两列、390/320px 单列且无横向溢出。桌面、深色和移动截图位于 `bin/dev-build/airy-layout`,未测试真实远端协议服务器。
## 集中查询条件(历史,2026-10-04)
React 版网关服务、目标服务及运行概览将项目、环境、资源搜索、协议、运行状态和分组放在标题下的同一个 HeroUI Card 中。范围清除、项目/环境管理和刷新按钮位于该卡片底部,查询字段随屏幕宽度自动换行;不再将项目/环境放在标题上方、其他条件放在标题下方。
项目/环境状态仍由 `App.tsx` 共享,资源查询仍由 `ResourcesPage.tsx` 处理,字段值、存储键、匹配规则与查询结果不变。其他工作区页面保留其原范围入口。只新增布局,不覆盖 HeroUI 默认组件样式,无 API 或数据库迁移。
生产构建及使用新嵌入 `bin/dev-build/query-group/linkport.exe`、隔离 SQLite / 18221、18222 的 13 项迁移与 domain Playwright 验收通过。额外浏览器检查确认六个字段位于同一查询区、清除范围可用,390px 无横向溢出;截图在 `bin/dev-build/query-group`。未连接真实协议服务器。
下文记录原 Vue 工作区交互;当前 React 架构与兼容变化见 [React 迁移](react-migration.md)。
2026-09-20 实现 UI-001 至 UI-006。代码与待验收状态见 [实现清单](todo.md),连接参数、连接标识及测试反馈见 [连接编辑器](connection-editor.md)。本文件描述实现行为,不代表浏览器运行态验收已经完成。
## 资源搜索与资源树
@@ -55,3 +158,13 @@
`npm.cmd --prefix web run build` 在 Vite 启动 esbuild 时遭遇 `spawn EPERM`;Playwright 的 worker 同样无法启动。测试发现与单进程逻辑断言不等于浏览器用例通过。本轮未生成新前端产物/新嵌入二进制,未运行浏览器端到端回归或生成新截图;不能沿用旧二进制或历史截图作为本轮验收。
解除环境限制后,先构建前端,再用 `GOTOOLCHAIN=go1.27.1` 编译新程序;将 `LINKPORT_E2E_BINARY` 指向新程序,使用隔离目录、空闲端口运行 `npm.cmd --prefix web test`。纯逻辑用例也可在 `web` 中运行 `npx.cmd playwright test --config playwright.domain.config.ts`。不得并行替换 `web/dist` 与编译 Go 嵌入资源。
## 账号 ID 自动维护(2026-10-13)
目标与网关的账号表单不再显示“账号 ID(必填)”输入框。ID 是内部关联键,新增账号已自动生成,编辑时保留原值;用户只需填写用户名、凭据等业务字段。保留提交字段和 ID 校验,不改变目标映射、已有凭据关联、导入导出格式或数据库迁移。
验证:前端与嵌入可执行文件构建通过,新程序下 12 项账号保存、字段反馈、草稿测试专项全部通过(13.3 秒)。使用隔离数据库与 fixtures,未连接真实目标或更改用户数据。仅移除前端字段,本轮未重复运行后端全量测试。
## 编辑页布局优化(2026-10-13)
目标、网关及其他详情编辑页限制为 1120px 并居中,表单按可用宽度排为一至两列,减少宽屏字段过密及窄屏拥挤。账号选择与添加/删除操作独立成栏,凭据字段从下一行开始。底部测试账号选择器获得合理宽度,按钮与字段底部对齐,长标签与提示允许换行。沿用 Ant Design 默认主题,只调整布局,不变更保存字段、验证或数据格式。
验证:前端及嵌入程序构建通过,使用新程序的 42 项浏览器专项全部通过(44.5 秒)。新增 1440、900、390、320px 布局检查,验证账号工具栏与凭据分行、表单列数、页面无横向溢出和保存按钮可见;已有保存、错误定位、退出保护及主题回归通过。使用隔离数据与 fixtures,未连接真实目标;本轮未重跑后端全量测试。
+3 -3
View File
@@ -11,7 +11,7 @@ import (
func TestAssetImpactTransitiveReferencesAndRunningState(t *testing.T) {
f := newFixture(t)
f.login()
if err := f.store.SaveAsset(model.KubeconfigAsset{ID: "impact-file", Name: "fixture file", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
if err := f.store.SaveAsset(model.Asset{ID: "impact-file", Name: "fixture file", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
t.Fatal(err)
}
w := f.request("POST", "/api/v1/outbounds", map[string]any{"name": "impact transport", "outbound": map[string]any{"mode": "k8s", "resource": "pod/db", "kubeConfigAssetId": "impact-file"}}, true)
@@ -77,7 +77,7 @@ func TestAssetImpactTransitiveReferencesAndRunningState(t *testing.T) {
func TestAssetImpactEmptyMissingAndMethod(t *testing.T) {
f := newFixture(t)
if err := f.store.SaveAsset(model.KubeconfigAsset{ID: "unused", Name: "unused", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
if err := f.store.SaveAsset(model.Asset{ID: "unused", Name: "unused", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
@@ -101,7 +101,7 @@ func TestAssetImpactEmptyMissingAndMethod(t *testing.T) {
func TestAssetImpactRouteRequiresLoginAndIgnoresDisplayScope(t *testing.T) {
f := newFixture(t)
if err := f.store.SaveAsset(model.KubeconfigAsset{ID: "impact-route", Name: "route", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
if err := f.store.SaveAsset(model.Asset{ID: "impact-route", Name: "route", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
t.Fatal(err)
}
path := "/api/v1/assets/impact-route/impact"
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"time"
)
func (s *Server) assetView(a model.KubeconfigAsset) any {
func (s *Server) assetView(a model.Asset) any {
refs, _ := s.store.AssetReferenceDetails(a.ID)
present := a.Content != ""
summary := a.Summary(time.Now())
@@ -55,7 +55,7 @@ func (s *Server) assets(w http.ResponseWriter, r *http.Request, path string) {
}
respond(w, 200, views)
case "POST":
s.saveAsset(w, r, model.KubeconfigAsset{Environment: model.DefaultEnvironment})
s.saveAsset(w, r, model.Asset{Environment: model.DefaultEnvironment})
default:
fail(w, 405, "method not allowed")
}
@@ -98,7 +98,7 @@ func (s *Server) assets(w http.ResponseWriter, r *http.Request, path string) {
}
}
func (s *Server) saveAsset(w http.ResponseWriter, r *http.Request, current model.KubeconfigAsset) {
func (s *Server) saveAsset(w http.ResponseWriter, r *http.Request, current model.Asset) {
var patch struct {
Project *string `json:"project"`
Environment *string `json:"environment"`
+3 -3
View File
@@ -31,7 +31,7 @@ func TestKubeconfigAssetLifecycleAndReferences(t *testing.T) {
w := f.request("POST", "/api/v1/assets", map[string]any{"name": "cluster", "type": "kubeconfig", "content": assetKubeconfigFixture}, true)
requireStatus(t, w, 201)
var v struct {
Config model.KubeconfigAsset `json:"config"`
Config model.Asset `json:"config"`
SecretStatus map[string]bool `json:"secretStatus"`
}
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
@@ -77,7 +77,7 @@ func TestKubeconfigAssetLifecycleAndReferences(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &target); err != nil {
t.Fatal(err)
}
w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": target.Config.ID, "targetAccountId": "db", "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "user", "gatewayPassword": "password"}, true)
w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": target.Config.ID, "targetAccountId": "db", "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayAccounts": []any{map[string]any{"id": "client", "username": "user", "password": "password"}}}, true)
requireStatus(t, w, 201)
gatewayID := readView(t, w).Config.ID
requireStatus(t, f.request("POST", "/api/v1/proxies/"+gatewayID+"/start", nil, true), 200)
@@ -104,7 +104,7 @@ func TestKubeconfigAssetLifecycleAndReferences(t *testing.T) {
func TestKubeconfigAssetRequiresLoginAndCSRF(t *testing.T) {
f := newFixture(t)
if err := f.store.SaveAsset(model.KubeconfigAsset{ID: "fixture", Name: "fixture", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
if err := f.store.SaveAsset(model.Asset{ID: "fixture", Name: "fixture", Type: "kubeconfig", Content: assetKubeconfigFixture}); err != nil {
t.Fatal(err)
}
requireStatus(t, f.request("GET", "/api/v1/assets/fixture", nil, false), 401)
+1 -1
View File
@@ -48,7 +48,7 @@ func TestAutomationScopesAndCredentialIsolation(t *testing.T) {
if err := f.store.SaveOutbound(model.OutboundProfile{ID: "kube", Name: "kube", Outbound: model.Outbound{Mode: "k8s", KubeConfig: "private-kube-secret"}}); err != nil {
t.Fatal(err)
}
template, err := f.store.SaveTemplate("private", store.Bundle{Version: 1, Gateways: []model.Proxy{{ID: "g", GatewayPassword: "private-gateway-secret", SSH: model.SSHConfig{HostKey: "private-key-secret"}}}})
template, err := f.store.SaveTemplate("private", store.Bundle{Version: 2, Gateways: []model.Proxy{{ID: "g", GatewayPassword: "private-gateway-secret", SSH: model.SSHConfig{HostKey: "private-key-secret"}}}})
if err != nil {
t.Fatal(err)
}
+2
View File
@@ -26,6 +26,8 @@ func (s *Server) configuration(w http.ResponseWriter, r *http.Request, path stri
action := strings.TrimPrefix(path, "configuration/")
w.Header().Set("Cache-Control", "no-store")
switch action {
case "startup":
s.startupSettings(w, r)
case "scopes":
s.scopeCatalog(w, r)
case "diff":
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"encoding/json"
"errors"
"linkport/internal/model"
"regexp"
)
var configurationFieldPath = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.\[\]-]{0,255}$`)
func configurationFieldsMessage(err error) string {
const summary = "invalid configuration fields"
var field *model.ConfigurationFieldError
if errors.As(err, &field) && configurationFieldPath.MatchString(field.Path) {
reason := map[string]string{
"unknown configuration field": "不支持此字段",
"configuration fields cannot be null": "不能为 null",
"configuration object expected": "必须是 JSON 对象",
}[field.Reason]
if reason != "" {
return summary + ";字段 " + field.Path + ":" + reason
}
}
var wrongType *json.UnmarshalTypeError
if errors.As(err, &wrongType) && configurationFieldPath.MatchString(wrongType.Field) {
return summary + ";字段 " + wrongType.Field + ":类型错误,需要 " + wrongType.Type.String()
}
return summary
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"strings"
"testing"
)
func TestGatewayConfigurationFieldErrors(t *testing.T) {
fixture := newFixture(t)
fixture.login()
gateway := fixture.create("mysql")
for _, testCase := range []struct {
name string
patch map[string]any
field string
}{
{"target account field", map[string]any{"gatewayAccounts": []any{map[string]any{"id": "new", "username": "user", "password": "fixture-secret", "authMethod": "password"}}}, "gatewayAccounts[0].authMethod"},
{"fractional limit", map[string]any{"maxConnections": 1.5}, "maxConnections"},
{"null limit", map[string]any{"maxConnections": nil}, "maxConnections"},
{"wrong account type", map[string]any{"gatewayAccounts": "fixture-secret"}, "gatewayAccounts"},
} {
t.Run(testCase.name, func(t *testing.T) {
response := fixture.request("PUT", "/api/v1/proxies/"+gateway.Config.ID, testCase.patch, true)
requireStatus(t, response, 400)
body := response.Body.String()
if !strings.Contains(body, testCase.field) || strings.Contains(body, "fixture-secret") {
t.Fatalf("missing field or exposed value: %s", body)
}
current, err := fixture.store.Get(gateway.Config.ID)
if err != nil || current.MaxConnections != gateway.Config.MaxConnections || len(current.GatewayAccounts) != len(gateway.Config.GatewayAccounts) {
t.Fatalf("rejected request changed config: %v", err)
}
})
}
}
+1 -1
View File
@@ -40,6 +40,6 @@ func TestConfigurationImportLargerThanOrdinaryRequestLimit(t *testing.T) {
target := model.TargetDefaults()
target.ID, target.Name, target.Type, target.TargetHost, target.TargetPort = "large-target", "large target", "mysql", "localhost", 3306
target.Accounts = []model.TargetAccount{{ID: "account", Remark: "account", Username: "alice", Password: strings.Repeat("x", 2<<20)}}
bundle := store.Bundle{Version: 1, Targets: []model.Target{target}}
bundle := store.Bundle{Version: 2, Targets: []model.Target{target}}
requireStatus(t, f.request("POST", "/api/v1/configuration/import", map[string]any{"bundle": bundle}, true), 200)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
func TestDatabaseSnapshotSessionAndPreview(t *testing.T) {
s, err := store.OpenPortable(filepath.Join(t.TempDir(), "portable.sqlite"), nil)
s, err := store.OpenPortable(filepath.Join(t.TempDir(), "portable.sqlite"))
if err != nil {
t.Fatal(err)
}
+4 -1
View File
@@ -16,7 +16,10 @@ func TestGatewayConnectionUsesAccountMapping(t *testing.T) {
}
target.TargetHost = "mapped.invalid"
target.Outbound = model.Outbound{Mode: "socks5", Host: "exit.invalid", Port: 1080, Auth: "none"}
target.OutboundID = ""
profile := model.OutboundProfile{ID: target.OutboundID, Name: "Mapped proxy", Outbound: target.Outbound}
if err = f.store.SaveOutbound(profile); err != nil {
t.Fatal(err)
}
if err = f.store.SaveTarget(target); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (s *Server) testGatewayDraft(w http.ResponseWriter, r *http.Request) {
}
p, err := model.Merge(current, body.Config)
if err != nil {
fail(w, 400, "invalid gateway fields")
fail(w, 400, configurationFieldsMessage(err))
return
}
if body.ProxyID != "" && p.Type != current.Type {
+2 -2
View File
@@ -162,7 +162,7 @@ func TestRunningConfigurationUpdatesPublishOnlyAfterSave(t *testing.T) {
gatewayResponse := f.request("POST", "/api/v1/proxies", map[string]any{
"name": "Live gateway", "type": "mysql", "listenHost": "127.0.0.1", "listenPort": listenPort,
"targetId": targetView.Config.ID, "targetAccountId": "upstream",
"gatewayUser": "fixture", "gatewayPassword": "gateway-secret", "allowCIDRs": []string{"127.0.0.1/32"},
"gatewayAccounts": []any{map[string]any{"id": "client", "username": "fixture", "password": "gateway-secret"}}, "allowCIDRs": []string{"127.0.0.1/32"},
}, true)
requireStatus(t, gatewayResponse, 201)
gatewayView := readView(t, gatewayResponse)
@@ -239,7 +239,7 @@ func TestLiveTargetSwitchDisconnectsClientsOnlyAfterSave(t *testing.T) {
gatewayResponse := f.request("POST", "/api/v1/proxies", map[string]any{
"name": "Switch gateway", "type": "mysql", "listenHost": "127.0.0.1", "listenPort": listenPort,
"targetId": oldTarget.ID, "targetAccountId": "upstream",
"gatewayUser": "fixture", "gatewayPassword": "gateway-secret", "allowCIDRs": []string{"127.0.0.1/32"},
"gatewayAccounts": []any{map[string]any{"id": "client", "username": "fixture", "password": "gateway-secret"}}, "allowCIDRs": []string{"127.0.0.1/32"},
}, true)
requireStatus(t, gatewayResponse, 201)
gatewayView := readView(t, gatewayResponse)
+1 -1
View File
@@ -62,7 +62,7 @@ func (s *Server) monitoring(w http.ResponseWriter, r *http.Request, path string)
}
out = append(out, map[string]any{"id": p.ID, "metrics": metrics, "health": latest, "accountHealth": accountHealth})
}
respond(w, 200, map[string]any{"gateways": out, "health": health, "intervalSeconds": interval})
respond(w, 200, map[string]any{"gateways": out, "health": health, "intervalSeconds": interval, "auditQueue": s.store.AuditQueueSnapshot()})
return
}
if r.Method == "POST" && path == "monitoring/settings" {
+2 -2
View File
@@ -42,8 +42,8 @@ func TestMySQLAccountModesPersistThroughAPI(t *testing.T) {
if view.Config.AccessMode != mode || view.Config.GatewayAccounts[0].AccessMode != "" {
t.Fatalf("gateway mode or inheritance lost: %+v", view.Config)
}
if got := view.Config.GatewayAccounts[0].Apply(view.Config).AccessMode; got != mode {
t.Fatalf("inherited effective mode=%s, want %s", got, mode)
if got := view.Config.GatewayAccounts[0].Apply(view.Config).AccessMode; got != "readwrite" {
t.Fatalf("default account mode=%s, want readwrite", got)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
package api
import (
"errors"
"linkport/internal/gateway"
"linkport/internal/model"
"linkport/internal/outbound"
"net/http"
"time"
)
func (s *Server) testNetworkOutbound(w http.ResponseWriter, r *http.Request, profile model.OutboundProfile) {
var options struct{}
if r.ContentLength != 0 && !decodeLimit(w, r, &options, 4096) {
return
}
started := time.Now()
resolved, err := s.store.GetResolvedOutbound(profile.ID)
if err != nil {
fail(w, 422, "无法加载代理凭据资产,请检查关联配置")
return
}
if resolved.Outbound.Mode == "socks5" {
if err = outbound.TestSOCKS5(r.Context(), resolved.Outbound); err != nil {
stage := gateway.FailureCategory(err)
respond(w, 422, map[string]any{"error": gateway.FailureMessage(stage), "stage": stage, "durationMs": time.Since(started).Milliseconds()})
return
}
respond(w, 200, map[string]any{"valid": true, "scope": "socks5_handshake", "authentication": resolved.Outbound.Auth, "message": "SOCKS5 握手及认证方式验证通过;未测试目标连接", "durationMs": time.Since(started).Milliseconds()})
return
}
result, err := outbound.TestKubernetes(r.Context(), resolved.Outbound)
if err != nil {
message := "Kubernetes 代理测试失败,请检查集群配置"
var discovery *outbound.KubernetesDiscoveryError
if errors.As(err, &discovery) {
message = discovery.Message
}
respond(w, 422, map[string]any{"error": message, "durationMs": time.Since(started).Milliseconds()})
return
}
response := map[string]any{"valid": result.Status == "ready", "scope": "kubernetes_permissions", "status": result.Status, "checks": result.Checks, "durationMs": time.Since(started).Milliseconds()}
if result.Status != "ready" {
response["error"] = "Kubernetes 权限测试未通过:无法确认权限或集群不可达"
if result.Status == "denied" {
response["error"] = "Kubernetes 权限测试未通过:资源读取或端口转发权限被拒绝"
}
respond(w, 422, response)
return
}
response["message"] = "Kubernetes API 访问及资源转发权限验证通过;未测试实际端口转发或目标连接"
respond(w, 200, response)
}
+98
View File
@@ -0,0 +1,98 @@
package api
import (
"encoding/json"
"fmt"
"io"
"linkport/internal/model"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSOCKS5OutboundConnectionEndpoint(t *testing.T) {
fixture := newFixture(t)
fixture.login()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
finished := make(chan error, 1)
go func() {
connection, err := listener.Accept()
if err != nil {
finished <- err
return
}
defer connection.Close()
connection.SetDeadline(time.Now().Add(3 * time.Second))
greeting := make([]byte, 3)
if _, err = io.ReadFull(connection, greeting); err == nil {
if string(greeting) != string([]byte{5, 1, 0}) {
err = fmt.Errorf("unexpected greeting %v", greeting)
} else {
_, err = connection.Write([]byte{5, 0})
}
}
finished <- err
}()
profile := model.OutboundProfile{ID: "socks-probe", Name: "probe", Outbound: model.Outbound{Mode: "socks5", Host: "127.0.0.1", Port: listener.Addr().(*net.TCPAddr).Port, Auth: "none"}}
if err = fixture.store.SaveOutbound(profile); err != nil {
t.Fatal(err)
}
path := "/api/v1/outbounds/" + profile.ID + "/test"
requireStatus(t, fixture.request("POST", path, map[string]any{}, false), 403)
requireStatus(t, fixture.request("POST", path, map[string]any{"unexpected": true}, true), 400)
response := fixture.request("POST", path, map[string]any{}, true)
requireStatus(t, response, 200)
if !strings.Contains(response.Body.String(), `"scope":"socks5_handshake"`) || !strings.Contains(response.Body.String(), `"valid":true`) {
t.Fatalf("unexpected response %s", response.Body.String())
}
if err = <-finished; err != nil {
t.Fatal(err)
}
}
func TestKubernetesOutboundConnectionEndpoint(t *testing.T) {
for _, status := range []string{"allowed", "denied", "unavailable"} {
t.Run(status, func(t *testing.T) {
fixture := newFixture(t)
fixture.login()
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" || r.Header.Get("Authorization") != "Bearer fixture-private-token" {
t.Error("unexpected Kubernetes request or missing resolved credential")
}
w.Header().Set("Content-Type", "application/json")
if status == "unavailable" {
w.WriteHeader(503)
fmt.Fprint(w, `{"message":"fixture-private-token"}`)
return
}
json.NewEncoder(w).Encode(map[string]any{"apiVersion": "authorization.k8s.io/v1", "kind": "SelfSubjectAccessReview", "status": map[string]any{"allowed": status == "allowed", "denied": status == "denied", "reason": "fixture-private-token"}})
}))
defer server.Close()
content := fmt.Sprintf("apiVersion: v1\nkind: Config\ncurrent-context: fixture\nclusters:\n- name: fixture\n cluster:\n server: %s\n insecure-skip-tls-verify: true\ncontexts:\n- name: fixture\n context:\n cluster: fixture\n user: fixture\nusers:\n- name: fixture\n user:\n token: fixture-private-token\n", server.URL)
asset := model.Asset{ID: "kube-test", Name: "kube test", Type: "kubeconfig", Content: content}
if err := fixture.store.SaveAsset(asset); err != nil {
t.Fatal(err)
}
profile := model.OutboundProfile{ID: "kube-probe", Name: "probe", Outbound: model.Outbound{Mode: "k8s", KubeConfigAssetID: asset.ID, Namespace: "default", Resource: "pod/database"}}
if err := fixture.store.SaveOutbound(profile); err != nil {
t.Fatal(err)
}
response := fixture.request("POST", "/api/v1/outbounds/"+profile.ID+"/test", map[string]any{}, true)
want := 422
if status == "allowed" {
want = 200
}
requireStatus(t, response, want)
if strings.Contains(response.Body.String(), "fixture-private-token") || !strings.Contains(response.Body.String(), `"scope":"kubernetes_permissions"`) {
t.Fatalf("invalid or sensitive response: %s", response.Body.String())
}
})
}
}
+5 -1
View File
@@ -64,6 +64,10 @@ func (s *Server) outbounds(w http.ResponseWriter, r *http.Request, path string,
return
}
if len(parts) == 3 && parts[2] == "test" && r.Method == "POST" {
if p.Outbound.Mode == "socks5" || p.Outbound.Mode == "k8s" {
s.testNetworkOutbound(w, r, p)
return
}
if p.Outbound.Mode == "sing-box" {
started := time.Now()
if err := outbound.CheckSingBox(r.Context(), p.Outbound.SingBoxOutbound); err != nil {
@@ -74,7 +78,7 @@ func (s *Server) outbounds(w http.ResponseWriter, r *http.Request, path string,
return
}
if p.Outbound.Mode != "ssh" {
fail(w, 400, "connection test is supported for SSH proxies")
fail(w, 400, "unsupported proxy type for connection testing")
return
}
started := time.Now()
+4 -5
View File
@@ -51,7 +51,7 @@ func TestSharedOutboundLifecycle(t *testing.T) {
tids = append(tids, tv.Config.ID)
}
requireStatus(t, f.request("DELETE", opath, nil, true), 409)
w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": tids[0], "targetAccountId": "db", "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "u", "gatewayPassword": "p"}, true)
w = f.request("POST", "/api/v1/proxies", map[string]any{"name": "gateway", "type": "mysql", "targetId": tids[0], "targetAccountId": "db", "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayAccounts": []any{map[string]any{"id": "client", "username": "u", "password": "p"}}}, true)
requireStatus(t, w, 201)
gid := readView(t, w).Config.ID
requireStatus(t, f.request("POST", "/api/v1/proxies/"+gid+"/start", nil, true), 200)
@@ -97,12 +97,11 @@ func TestSharedOutboundLifecycle(t *testing.T) {
}
requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outboundId": "missing"}, true), 400)
requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outboundId": "", "outbound": map[string]any{"mode": "direct"}}, true), 400)
// Inline compatibility forks the route rather than modifying other references.
requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outbound": map[string]any{"host": "fork.internal"}}, true), 200)
requireStatus(t, f.request("PUT", "/api/v1/targets/"+tids[0], map[string]any{"outbound": map[string]any{"host": "fork.internal"}}, true), 400)
first, _ := f.store.GetTarget(tids[0])
second, _ := f.store.GetTarget(tids[1])
if first.OutboundID == second.OutboundID || second.Outbound.Host != "changed.internal" {
t.Fatal("legacy inline update modified shared profile")
if first.OutboundID != second.OutboundID || second.Outbound.Host != "changed.internal" {
t.Fatal("rejected inline update modified shared profile")
}
for _, id := range tids {
requireStatus(t, f.request("PUT", "/api/v1/targets/"+id, map[string]any{"outboundId": ""}, true), 200)
+2 -2
View File
@@ -45,7 +45,7 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
}
p, err := model.Merge(base, patch)
if err != nil {
fail(w, 400, "invalid configuration fields")
fail(w, 400, configurationFieldsMessage(err))
return
}
p.ID = security.Token()[:16]
@@ -91,7 +91,7 @@ func (s *Server) update(w http.ResponseWriter, r *http.Request, p model.Proxy) {
}
next, err := model.Merge(p, patch)
if err != nil {
fail(w, 400, "invalid configuration fields")
fail(w, 400, configurationFieldsMessage(err))
return
}
if next.Type != p.Type {
+3 -3
View File
@@ -102,7 +102,7 @@ func TestRedisGatewayDefaultAndAccountOverrideAllowAccountlessTarget(t *testing.
defaultResponse := f.request("POST", "/api/v1/proxies", map[string]any{
"name": "Default Redis gateway", "type": "redis", "listenHost": "127.0.0.1", "listenPort": freePort(t),
"targetId": target.ID, "gatewayUser": "client", "gatewayPassword": "client-secret",
"targetId": target.ID, "gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "client-secret"}},
}, true)
requireStatus(t, defaultResponse, 201)
defaultGateway := readView(t, defaultResponse).Config
@@ -133,7 +133,7 @@ func TestRedisGatewaySwitchDistinguishesAccountlessAndUnselectedAccount(t *testi
w := f.request("POST", "/api/v1/proxies", map[string]any{
"name": "Switch Redis gateway", "type": "redis", "listenHost": "127.0.0.1", "listenPort": freePort(t),
"targetId": withAccount.ID, "targetAccountId": "configured", "gatewayUser": "client", "gatewayPassword": "client-secret",
"targetId": withAccount.ID, "targetAccountId": "configured", "gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "client-secret"}},
}, true)
requireStatus(t, w, 201)
gatewayID := readView(t, w).Config.ID
@@ -151,7 +151,7 @@ func TestRedisGatewaySwitchDistinguishesAccountlessAndUnselectedAccount(t *testi
requireStatus(t, f.request("POST", "/api/v1/proxies", map[string]any{
"name": "Invalid Redis selection", "type": "redis", "listenHost": "127.0.0.1", "listenPort": freePort(t),
"targetId": withAccount.ID, "gatewayUser": "client", "gatewayPassword": "client-secret",
"targetId": withAccount.ID, "gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "client-secret"}},
}, true), 400)
partialResponse := f.request("POST", "/api/v1/proxies", map[string]any{
+17 -4
View File
@@ -210,6 +210,18 @@ func (f *fixture) createConfigured(c map[string]any) *httptest.ResponseRecorder
}
}
target := map[string]any{"name": "Test target", "type": c["type"], "targetHost": c["targetHost"], "targetPort": c["targetPort"], "outbound": c["outbound"], "accounts": []any{account}}
if outbound, ok := c["outbound"].(map[string]any); ok && outbound["mode"] != "direct" {
response := f.request("POST", "/api/v1/outbounds", map[string]any{"name": "Fixture proxy", "outbound": outbound}, true)
requireStatus(f.t, response, 201)
var profile struct {
Config model.OutboundProfile `json:"config"`
}
if err := json.Unmarshal(response.Body.Bytes(), &profile); err != nil {
f.t.Fatal(err)
}
delete(target, "outbound")
target["outboundId"] = profile.Config.ID
}
w := f.request("POST", "/api/v1/targets", target, true)
requireStatus(f.t, w, 201)
var v struct {
@@ -217,7 +229,8 @@ func (f *fixture) createConfigured(c map[string]any) *httptest.ResponseRecorder
}
json.Unmarshal(w.Body.Bytes(), &v)
gateway := map[string]any{"targetId": v.Config.ID, "targetAccountId": "test-account"}
for _, key := range []string{"name", "type", "listenHost", "listenPort", "allowCIDRs", "gatewayUser", "gatewayPassword", "accessMode"} {
gateway["gatewayAccounts"] = []any{map[string]any{"id": "client", "username": c["gatewayUser"], "password": c["gatewayPassword"]}}
for _, key := range []string{"name", "type", "listenHost", "listenPort", "allowCIDRs", "accessMode"} {
if value, ok := c[key]; ok {
gateway[key] = value
}
@@ -257,7 +270,7 @@ func TestDefaultReadwriteRedactionAndSecretPreservingUpdate(t *testing.T) {
if v.Config.AccessMode != "readwrite" {
t.Fatalf("%s does not default readwrite", kind)
}
if !v.Flags["targetPassword"] || !v.Flags["gatewayPassword"] || !v.Flags["socksPassword"] {
if !v.Flags["targetPassword"] || !v.Flags["gatewayAccount:client:password"] || !v.Flags["socksPassword"] {
t.Fatal("secret presence flags missing")
}
if v.Config.TargetPassword != "" || v.Config.GatewayPassword != "" || v.Config.Outbound.Password != "" {
@@ -266,12 +279,12 @@ func TestDefaultReadwriteRedactionAndSecretPreservingUpdate(t *testing.T) {
path := "/api/v1/proxies/" + v.Config.ID
w := f.request("PUT", path, map[string]any{"name": "renamed", "accessMode": "readonly"}, true)
requireStatus(t, w, 200)
requireStatus(t, f.request("PUT", "/api/v1/targets/"+v.Config.TargetID, map[string]any{"outbound": map[string]any{"host": "new-socks.example"}}, true), 200)
requireStatus(t, f.request("PUT", "/api/v1/outbounds/"+v.Config.OutboundID, map[string]any{"outbound": map[string]any{"host": "new-socks.example"}}, true), 200)
saved, err := f.store.Get(v.Config.ID)
if err != nil {
t.Fatal(err)
}
if saved.TargetPassword != "target-secret-unique" || saved.GatewayPassword != "gateway-secret-unique" || saved.Outbound.Password != "socks-secret-unique" || saved.AccessMode != "readonly" {
if saved.TargetPassword != "target-secret-unique" || saved.GatewayAccounts[0].Password != "gateway-secret-unique" || saved.Outbound.Password != "socks-secret-unique" || saved.AccessMode != "readonly" {
t.Fatal("partial update lost secret or mode")
}
}
+35
View File
@@ -0,0 +1,35 @@
package api
import (
"net/http"
"os"
)
func (s *Server) startupSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPut {
fail(w, 405, "method not allowed")
return
}
if r.Method == http.MethodPut {
var body struct {
HTTPPort *int `json:"httpPort"`
}
if !decode(w, r, &body) {
return
}
if body.HTTPPort == nil || *body.HTTPPort < 0 || *body.HTTPPort > 65535 {
fail(w, 400, "请填写管理端口:0–65535 的整数(0 表示自动分配)")
return
}
if err := s.store.SaveStartupPort(*body.HTTPPort); err != nil {
fail(w, 500, "无法保存启动端口,请检查数据库是否可写")
return
}
}
port, err := s.store.StartupPort()
if err != nil {
fail(w, 500, "无法读取启动端口")
return
}
respond(w, 200, map[string]any{"httpPort": port, "environmentOverride": os.Getenv("LINKPORT_HTTP_ADDR")})
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"encoding/json"
"testing"
)
func TestStartupSettings(t *testing.T) {
fixture := newFixture(t)
fixture.login()
path := "/api/v1/configuration/startup"
requireStatus(t, fixture.request("PUT", path, map[string]any{"httpPort": 25989}, false), 403)
for _, port := range []any{-1, 65536, 2.5, "25989", nil} {
requireStatus(t, fixture.request("PUT", path, map[string]any{"httpPort": port}, true), 400)
}
requireStatus(t, fixture.request("PUT", path, map[string]any{}, true), 400)
for _, port := range []int{25989, 65535, 0} {
requireStatus(t, fixture.request("PUT", path, map[string]any{"httpPort": port}, true), 200)
response := fixture.request("GET", path, nil, false)
requireStatus(t, response, 200)
var settings struct {
HTTPPort int `json:"httpPort"`
}
if err := json.Unmarshal(response.Body.Bytes(), &settings); err != nil || settings.HTTPPort != port {
t.Fatalf("port not saved: %s, %v", response.Body, err)
}
}
requireStatus(t, fixture.request("DELETE", path, nil, true), 405)
}
+6 -9
View File
@@ -7,7 +7,7 @@ import (
"testing"
)
func TestTargetAccountRemarkAPICompatibility(t *testing.T) {
func TestTargetAccountRemarkRejectsOldFormat(t *testing.T) {
f := newFixture(t)
f.login()
readAccount := func(wbody []byte) (string, bool, string) {
@@ -46,16 +46,12 @@ func TestTargetAccountRemarkAPICompatibility(t *testing.T) {
w = f.request("PUT", "/api/v1/targets/"+id, map[string]any{
"accounts": []any{map[string]any{"id": "account", "name": "legacy note", "username": "user"}},
}, true)
requireStatus(t, w, 200)
remark, hasName, _ = readAccount(w.Body.Bytes())
if remark != "legacy note" || hasName {
t.Fatalf("legacy account metadata = remark %q, legacy name present %t", remark, hasName)
}
requireStatus(t, w, 400)
w = f.request("GET", "/api/v1/targets/"+id, nil, false)
requireStatus(t, w, 200)
remark, hasName, _ = readAccount(w.Body.Bytes())
if remark != "legacy note" || hasName {
if remark != "" || hasName {
t.Fatalf("persisted account metadata = remark %q, legacy name present %t", remark, hasName)
}
}
@@ -78,7 +74,7 @@ func TestTargetAccountsGatewaySelection(t *testing.T) {
path := "/api/v1/targets/" + view.Config.ID
ids := []string{}
for _, accountID := range []string{"normal", "root"} {
gateway := map[string]any{"name": "gateway", "type": "ssh", "targetId": view.Config.ID, "targetAccountId": accountID, "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayUser": "client", "gatewayPassword": "gateway-secret"}
gateway := map[string]any{"name": "gateway", "type": "ssh", "targetId": view.Config.ID, "targetAccountId": accountID, "listenHost": "127.0.0.1", "listenPort": freePort(t), "gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "gateway-secret"}}}
w = f.request("POST", "/api/v1/proxies", gateway, true)
requireStatus(t, w, 201)
if strings.Contains(w.Body.String(), "root-secret") {
@@ -154,7 +150,8 @@ func TestOldAccountDesignRejectedAndUnboundGatewayCanBeReconfigured(t *testing.T
}
p := model.Defaults()
p.ID, p.TargetID, p.Name, p.Type = "unbound", target.ID, "Old gateway", "mysql"
p.GatewayUser, p.GatewayPassword, p.ListenPort = "client", "gateway-secret", freePort(t)
p.ListenPort = freePort(t)
p.GatewayAccounts = []model.GatewayAccount{{ID: "client", Username: "client", Password: "gateway-secret"}}
if err := f.store.Save(p); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -35,7 +35,7 @@ func TestRunningSSHTargetUpdateRequiresCandidateFingerprint(t *testing.T) {
gatewayResponse := f.request("POST", "/api/v1/proxies", map[string]any{
"name": "SSH gateway", "type": protocol, "listenHost": "127.0.0.1", "listenPort": freePort(t),
"targetId": targetView.Config.ID, "targetAccountId": "upstream",
"gatewayUser": "fixture", "gatewayPassword": "gateway-password",
"gatewayAccounts": []any{map[string]any{"id": "client", "username": "fixture", "password": "gateway-password"}},
}, true)
requireStatus(t, gatewayResponse, 201)
gatewayView := readView(t, gatewayResponse)
+1 -1
View File
@@ -25,7 +25,7 @@ func TestSharedTargetLifecycle(t *testing.T) {
tid := tv.Config.ID
var gateways []string
for i := 0; i < 2; i++ {
config := map[string]any{"name": "gateway", "type": "mysql", "targetId": tid, "listenHost": "127.0.0.1", "listenPort": freePort(t), "allowCIDRs": []string{"127.0.0.1"}, "gatewayUser": "client", "gatewayPassword": "gateway-secret"}
config := map[string]any{"name": "gateway", "type": "mysql", "targetId": tid, "listenHost": "127.0.0.1", "listenPort": freePort(t), "allowCIDRs": []string{"127.0.0.1"}, "gatewayAccounts": []any{map[string]any{"id": "client", "username": "client", "password": "gateway-secret"}}}
config["targetAccountId"] = "normal"
w = f.request("POST", "/api/v1/proxies", config, true)
requireStatus(t, w, 201)
+10 -11
View File
@@ -15,11 +15,11 @@ func TestWebDAVSyncSettingsAndAuthorization(t *testing.T) {
path := "/api/v1/configuration/webdav/settings"
requireStatus(t, f.request("GET", path, nil, false), 401)
f.login()
body := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: "https://dav.example.com/root/", Username: "access", Password: "secret-never-return"}, IntervalMinutes: 5, Passphrase: "pass-never-return"}
body := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: "https://dav.example.com/root/", Username: "access", Password: "secret-never-return"}, IntervalMinutes: 5}
requireStatus(t, f.request("PUT", path, body, false), 403)
w := f.request("PUT", path, body, true)
requireStatus(t, w, 200)
for _, secret := range []string{body.Password, body.Passphrase} {
for _, secret := range []string{body.Password} {
if strings.Contains(w.Body.String(), secret) {
t.Fatal("settings echoed secrets")
}
@@ -30,15 +30,14 @@ func TestWebDAVSyncSettingsAndAuthorization(t *testing.T) {
t.Fatal("settings can be cached")
}
var view map[string]any
if json.Unmarshal(w.Body.Bytes(), &view) != nil || view["hasPassphrase"] != true {
t.Fatal("manual sync passphrase was not retained")
if json.Unmarshal(w.Body.Bytes(), &view) != nil || view["hasPassword"] != true {
t.Fatal("WebDAV password status was not returned")
}
body.Password = ""
body.Passphrase = ""
body.Automatic = true
requireStatus(t, f.request("PUT", path, body, true), 200)
saved, err := f.store.WebDAVSyncSettings()
if err != nil || saved.Password != "secret-never-return" || saved.Passphrase != "pass-never-return" {
if err != nil || saved.Password != "secret-never-return" {
t.Fatal("blank secrets were not preserved")
}
body.Endpoint = "https://another.example.com/dav/"
@@ -63,12 +62,12 @@ func TestWebDAVSyncSettingsAndAuthorization(t *testing.T) {
"intervalMinutes": 5, "clearPassword": true,
}, true), 200)
saved, err = f.store.WebDAVSyncSettings()
if err != nil || saved.Password != "" || saved.Passphrase != "pass-never-return" {
t.Fatal("explicit password clearing changed the encryption passphrase")
if err != nil || saved.Password != "" {
t.Fatal("explicit password clearing failed")
}
body.Automatic = false
body.Passphrase = "short"
requireStatus(t, f.request("PUT", path, body, true), 400)
requireStatus(t, f.request("PUT", path, map[string]any{"passphrase": "old-setting"}, true), 400)
requireStatus(t, f.request("POST", "/api/v1/configuration/webdav/sync", map[string]any{"passphrase": "old-setting"}, true), 400)
requireStatus(t, f.request("POST", "/api/v1/configuration/s3/backup", map[string]any{}, true), 404)
requireStatus(t, f.request("GET", "/api/v1/configuration/s3/settings", nil, false), 404)
requireStatus(t, f.request("POST", "/api/v1/configuration/webdav/backup", map[string]any{}, false), 403)
@@ -104,7 +103,7 @@ func TestWebDAVSyncEndpointAndNoProviderSecretLeak(t *testing.T) {
w.Write([]byte("provider-echo-secret"))
}))
defer service.Close()
settings := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: service.URL, Username: "key", Password: "secret"}, IntervalMinutes: 5, Passphrase: "shared-sync-passphrase"}
settings := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: service.URL, Username: "key", Password: "secret"}, IntervalMinutes: 5}
requireStatus(t, f.request("PUT", "/api/v1/configuration/webdav/settings", settings, true), 200)
w := f.request("POST", "/api/v1/configuration/webdav/sync", map[string]any{}, true)
requireStatus(t, w, 502)
+7 -6
View File
@@ -53,21 +53,20 @@ func TestResolveDataPath(testContext *testing.T) {
configDirectory = ""
}
testContext.Setenv("APPDATA", configDirectory)
testContext.Setenv("USERPROFILE", root)
masterKey := ""
if testCase.manualKey {
masterKey = "resolution must not validate or decrypt this key"
}
testContext.Setenv("LINKPORT_MASTER_KEY", masterKey)
configured := testCase.configured
expected := localPath
expected := filepath.Join(root, ".linkport", "linkport.sqlite")
if configured != "" {
expected = filepath.Join(workingDirectory, configured)
if testCase.absolute {
configured = filepath.Join(root, configured)
expected = configured
}
} else if testCase.wantConfig {
expected = configPath
}
testContext.Setenv("LINKPORT_DATA", configured)
actual, err := ResolveDataPath()
@@ -90,11 +89,12 @@ func TestResolveDataPath(testContext *testing.T) {
func TestInvalidLocalDatabaseDoesNotFallBack(testContext *testing.T) {
root := testContext.TempDir()
testContext.Setenv("USERPROFILE", root)
testContext.Chdir(root)
testContext.Setenv("LINKPORT_DATA", "")
testContext.Setenv("LINKPORT_MASTER_KEY", "")
testContext.Setenv("APPDATA", filepath.Join(root, "AppData"))
localPath := filepath.Join(root, "data", "linkport.sqlite")
localPath := filepath.Join(root, ".linkport", "linkport.sqlite")
if err := os.MkdirAll(localPath, 0700); err != nil {
testContext.Fatal(err)
}
@@ -105,11 +105,12 @@ func TestInvalidLocalDatabaseDoesNotFallBack(testContext *testing.T) {
func TestInvalidConfigDatabaseDoesNotCreateLocal(testContext *testing.T) {
root := testContext.TempDir()
testContext.Setenv("USERPROFILE", root)
testContext.Chdir(root)
testContext.Setenv("LINKPORT_DATA", "")
testContext.Setenv("LINKPORT_MASTER_KEY", "")
testContext.Setenv("APPDATA", filepath.Join(root, "AppData"))
configDirectory := filepath.Join(root, "AppData", "linkport")
configDirectory := filepath.Join(root, ".linkport")
if err := os.MkdirAll(configDirectory, 0700); err != nil {
testContext.Fatal(err)
}
@@ -122,7 +123,7 @@ func TestInvalidConfigDatabaseDoesNotCreateLocal(testContext *testing.T) {
testContext.Fatal(err)
}
defer config.Close()
if database, err := store.OpenPortable(config.Data, nil); err == nil {
if database, err := store.OpenPortable(config.Data); err == nil {
database.Close()
testContext.Fatal("opened an invalid legacy database")
}
+1 -24
View File
@@ -6,7 +6,6 @@ import (
"golang.org/x/sys/windows"
"os"
"os/exec"
"path/filepath"
"strings"
"unsafe"
)
@@ -66,29 +65,7 @@ func lock(path string) (func(), error) {
}
return nil, err
}
legacyPath := filepath.Join(filepath.Dir(path), "desktop.lock")
var legacyHandle windows.Handle
if _, err = os.Stat(legacyPath); err == nil {
legacyName, convertErr := windows.UTF16PtrFromString(legacyPath)
if convertErr != nil {
windows.CloseHandle(h)
return nil, convertErr
}
legacyHandle, err = windows.CreateFile(legacyName, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
windows.CloseHandle(h)
return nil, err
}
} else if !os.IsNotExist(err) {
windows.CloseHandle(h)
return nil, err
}
return func() {
if legacyHandle != 0 {
windows.CloseHandle(legacyHandle)
}
windows.CloseHandle(h)
}, nil
return func() { windows.CloseHandle(h) }, nil
}
func OpenBrowser(url string) error {
+1 -23
View File
@@ -1,8 +1,6 @@
package desktop
import (
"bytes"
"encoding/base64"
"linkport/internal/store"
"os"
"path/filepath"
@@ -27,7 +25,7 @@ func TestLocalSingleFileAndLock(testContext *testing.T) {
testContext.Fatal(err)
}
defer config.Close()
database, err := store.OpenPortable(config.Data, nil)
database, err := store.OpenPortable(config.Data)
if err != nil {
testContext.Fatal(err)
}
@@ -42,23 +40,3 @@ func TestLocalSingleFileAndLock(testContext *testing.T) {
testContext.Fatalf("expected only SQLite: %v, %v", entries, err)
}
}
func TestReadLegacyDPAPIKey(testContext *testing.T) {
directory := testContext.TempDir()
key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))
blob, err := protect([]byte(key))
if err != nil {
testContext.Fatal(err)
}
path := filepath.Join(directory, "master-key.dpapi")
if err = os.WriteFile(path, blob, 0600); err != nil {
testContext.Fatal(err)
}
actual, err := ReadLegacyKey(filepath.Join(directory, "linkport.sqlite"))
if err != nil || actual != key {
testContext.Fatalf("legacy key: %v", err)
}
if actual, err := os.ReadFile(path); err != nil || !bytes.Equal(actual, blob) {
testContext.Fatal("legacy file was modified")
}
}
+10 -1
View File
@@ -78,6 +78,9 @@ func TestOneListenerRoutesAccountsToDifferentTargets(t *testing.T) {
}
}
m.mu.Unlock()
// Audit persistence is asynchronous; routing assertions above remain immediate.
deadline := time.Now().Add(3 * time.Second)
for {
commands, err := m.store.Commands(p.ID, "", 0)
if err != nil {
t.Fatal(err)
@@ -86,9 +89,15 @@ func TestOneListenerRoutesAccountsToDifferentTargets(t *testing.T) {
for _, command := range commands {
seen[command.TargetID] = true
}
if !seen[first.ID] || !seen[second.ID] {
if seen[first.ID] && seen[second.ID] {
break
}
if time.Now().After(deadline) {
t.Fatal("command audit did not record selected targets")
}
time.Sleep(10 * time.Millisecond)
}
// Change only account A's mapping. The switch disconnects every current client;
// reconnecting both accounts uses the newly published routing snapshot.
next := p
+3
View File
@@ -52,7 +52,10 @@ func ObservedFailureCategory(err error, diagnostics outbound.Diagnostics) string
if stage.Kind == "permission" {
return "target_permission"
}
if stage.Kind == "protocol" {
return "target_protocol"
}
return "target_session"
case "http_response":
if stage.Kind == "authentication" {
return "target_authentication"
+3
View File
@@ -46,6 +46,9 @@ func TestObservedFailureCategoryUsesObservedProtocolStages(t *testing.T) {
}{
{"target_handshake", "protocol", "target_handshake"},
{"protocol_session", "protocol", "target_protocol"},
{"protocol_session", "unknown", "target_session"},
{"protocol_session", "network", "target_session"},
{"protocol_session", "", "target_session"},
{"protocol_session", "authentication", "target_authentication"},
{"target_query", "permission", "target_permission"},
{"http_response", "protocol", "target_http_response"},
+2 -1
View File
@@ -28,7 +28,7 @@ func FailureCategory(err error) string {
case "target_query":
return "target_query"
case "protocol_session":
return "target_protocol"
return "target_session"
case "http_response":
return "target_http_response"
case "authentication_rejected":
@@ -112,6 +112,7 @@ func targetConnectCategory(err error) string {
}
var failureMessages = map[string]string{
"target_session": "目标会话建立失败;现有诊断尚不能确定是握手、TLS、认证交换还是会话初始化问题,请结合已完成的连接阶段和服务端日志排查",
"target_connect": "无法建立目标 TCP 连接;请检查目标地址、端口、网络及监听状态,尚未进入账号认证",
"target_connect_refused": "目标 TCP 连接被拒绝;通常表示目标地址可达但该端口没有服务监听,或被防火墙主动拒绝,尚未进入账号认证",
"target_connect_timeout": "目标 TCP 连接超时;请检查网络路由、防火墙、代理出口及目标服务状态,尚未进入账号认证",
+2 -2
View File
@@ -59,10 +59,10 @@ func (s *connectionSession) serve(handler model.Handler) {
func (s *connectionSession) commandContext() context.Context {
ctx := model.WithCommandAudit(s.ctx, func(command model.Command) error {
return s.manager.store.RecordCommand(s.auditSnapshot(), s.connection.Source, command)
return s.manager.store.EnqueueCommand(s.auditSnapshot(), s.connection.Source, command)
})
return model.WithCommandOutputAudit(ctx, func(output model.CommandOutput) error {
return s.manager.store.RecordCommandOutput(s.auditSnapshot(), s.connection.Source, output)
return s.manager.store.EnqueueCommandOutput(s.auditSnapshot(), s.connection.Source, output)
})
}
@@ -83,6 +83,7 @@ func TestSwitchTargetSSHReconnectUsesNewServerAndCredentials(t *testing.T) {
m.handlers["ssh"] = sshproxy.New()
p.Type, p.MaxConnections = "ssh", 10
p.GatewayUser, p.GatewayPassword = "gateway", "gateway-password"
p.GatewayAccounts = []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-password"}}
var err error
p.SSH.HostKey, err = sshproxy.GenerateHostKey()
if err != nil {
+1 -4
View File
@@ -12,13 +12,10 @@ func TestMySQLAccountAccessModes(t *testing.T) {
if err := p.ValidateGateway(); err != nil {
t.Fatal(err)
}
want := gateway
want := account
if want == "" {
want = "readwrite"
}
if want != "readonly" && account != "" {
want = account
}
if got := p.GatewayAccounts[0].Apply(p).AccessMode; got != want {
t.Fatalf("effective mode = %q, want %q", got, want)
}
+1 -4
View File
@@ -9,7 +9,7 @@ import (
)
type Asset struct {
Project string `json:"project,omitempty"`
Project string `json:"-"`
Environment string `json:"environment"`
Filename string `json:"filename,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
@@ -20,9 +20,6 @@ type Asset struct {
Content string `json:"content,omitempty"`
}
// KubeconfigAsset remains an alias for compatibility with existing backups.
type KubeconfigAsset = Asset
func (a Asset) Validate() error {
if err := ValidateScope("", a.Environment); err != nil {
return err
@@ -0,0 +1,10 @@
package model
type ConfigurationFieldError struct {
Path string
Reason string
}
func (e *ConfigurationFieldError) Error() string {
return e.Path + ": " + e.Reason
}
+3 -2
View File
@@ -60,9 +60,10 @@ func (p Proxy) selectTargetAccount(override string) Proxy {
if override != "" {
p.TargetAccountID = override
}
if len(p.TargetAccounts) == 0 && override == "" {
if len(p.TargetAccounts) == 0 && override == "" && (p.Type == "tcp" || p.Type == "redis" || p.Type == "mongodb") {
p.TargetUser, p.TargetPassword = "", ""
return p
} // Legacy fixture/runtime.
}
for _, a := range p.TargetAccounts {
if a.ID == p.TargetAccountID {
return a.Apply(p)
+4 -17
View File
@@ -26,9 +26,9 @@ type GatewayAccount struct {
// Redis and MongoDB gateways with no client accounts accept downstream
// connections without a local authentication step. Every other gateway,
// including legacy credentials, requires gateway authentication.
// requires gateway authentication.
func (p Proxy) RequiresGatewayAuthentication() bool {
return (p.Type != "redis" && p.Type != "mongodb") || len(p.GatewayAccounts) > 0 || p.GatewayUser != "" || p.GatewayPassword != ""
return (p.Type != "redis" && p.Type != "mongodb") || len(p.GatewayAccounts) > 0
}
func (p Proxy) GatewayAccount(username string) (GatewayAccount, bool) {
@@ -46,11 +46,6 @@ func (p Proxy) GatewayAccount(username string) (GatewayAccount, bool) {
}
}
}
legacyMatch := username == p.GatewayUser || (p.Type == "redis" && p.GatewayUser == "" && username == "default")
legacyConfigured := p.GatewayUser != "" || (p.Type == "redis" && p.GatewayPassword != "")
if len(p.GatewayAccounts) == 0 && legacyMatch {
return GatewayAccount{ID: "legacy", Username: p.GatewayUser, Password: p.GatewayPassword, AccessMode: p.AccessMode, AllowCIDRs: p.AllowCIDRs, AuthorizedKeys: p.SSH.AuthorizedKeys}, legacyConfigured
}
return GatewayAccount{}, false
}
func (a GatewayAccount) Apply(p Proxy) Proxy {
@@ -70,17 +65,12 @@ func (a GatewayAccount) Apply(p Proxy) Proxy {
if a.Disabled || credentialExpired(a.ExpiresAt, time.Now()) {
p.TargetCredentialError = ErrCredentialUnavailable
}
// Gateway readonly is an upper bound. Otherwise accounts may choose their
// own MySQL policy, including unrestricted SQL delegated to upstream grants.
readonly := p.AccessMode == "readonly"
p.GatewayUser, p.GatewayPassword = a.Username, a.Password
if a.AccessMode != "" {
p.AccessMode = a.AccessMode
if p.AccessMode == "" && !IsSSH(p.Type) {
p.AccessMode = "readwrite"
}
p.SSH.AuthorizedKeys = a.AuthorizedKeys
if readonly {
p.AccessMode = "readonly"
}
return p
}
@@ -149,9 +139,6 @@ func mergeGatewaySecrets(current Proxy, patch map[string]any, out *Proxy) error
for _, a := range current.GatewayAccounts {
byID[a.ID] = a
}
if len(current.GatewayAccounts) == 0 {
byID["legacy"] = GatewayAccount{Password: current.GatewayPassword}
}
for i, entry := range entries {
fields, ok := entry.(map[string]any)
if !ok {
+5 -6
View File
@@ -37,8 +37,8 @@ func TestGatewayAccountSelectionAndMerge(t *testing.T) {
}
p.AccessMode = "readonly"
a := GatewayAccount{AccessMode: "readwrite"}
if a.Apply(p).AccessMode != "readonly" {
t.Fatal("account elevated gateway permissions")
if a.Apply(p).AccessMode != "readwrite" {
t.Fatal("explicit account policy was overridden by legacy gateway mode")
}
}
func TestGatewayValidationAndRequiredTargetUser(t *testing.T) {
@@ -56,8 +56,7 @@ func TestGatewayValidationAndRequiredTargetUser(t *testing.T) {
p.Name = "gateway"
p.Type = "redis"
p.ListenPort = 6379
p.GatewayUser = "user"
p.GatewayPassword = "pass"
p.GatewayAccounts = []GatewayAccount{{ID: "client", Username: "user", Password: "pass"}}
if err := p.ValidateGateway(); err != nil {
t.Fatal(err)
}
@@ -77,11 +76,11 @@ func TestGatewayValidationAndRequiredTargetUser(t *testing.T) {
t.Fatal("blank target username accepted")
}
p.TargetUser = "validation"
p.GatewayUser = ""
p.GatewayAccounts[0].Username = ""
if err := p.ValidateGateway(); err != nil {
t.Fatalf("Redis password-only gateway rejected: %v", err)
}
p.GatewayUser = " "
p.GatewayAccounts[0].Username = " "
if err := p.ValidateGateway(); err == nil {
t.Fatal("blank gateway username accepted")
}
+2 -2
View File
@@ -34,8 +34,8 @@ type Proxy struct {
TargetPort int `json:"targetPort"`
TargetUser string `json:"targetUser"`
TargetPassword string `json:"targetPassword,omitempty"`
GatewayUser string `json:"gatewayUser"`
GatewayPassword string `json:"gatewayPassword,omitempty"`
GatewayUser string `json:"-"`
GatewayPassword string `json:"-"`
AccessMode string `json:"accessMode,omitempty"`
MaxConnections int `json:"maxConnections"`
Enabled bool `json:"enabled"`
-62
View File
@@ -1,7 +1,6 @@
package model
import (
"encoding/json"
"errors"
"strings"
"unicode/utf8"
@@ -10,67 +9,6 @@ import (
const DefaultProject = "默认项目"
const DefaultEnvironment = "test"
// Missing fields in older backups/history receive the agreed default ownership.
// Explicit empty fields remain empty so users can clear a classification.
func (a *Asset) UnmarshalJSON(data []byte) error {
type plain Asset
v := plain(*a)
if v.Environment == "" {
v.Environment = DefaultEnvironment
}
if err := json.Unmarshal(data, &v); err != nil {
return err
}
v.Project = ""
*a = Asset(v)
return nil
}
func (p *Proxy) UnmarshalJSON(data []byte) error {
type plain Proxy
v := plain(*p)
if v.Project == "" {
v.Project = DefaultProject
}
if v.Environment == "" {
v.Environment = DefaultEnvironment
}
if err := json.Unmarshal(data, &v); err != nil {
return err
}
*p = Proxy(v)
return nil
}
func (t *Target) UnmarshalJSON(data []byte) error {
type plain Target
v := plain(*t)
if v.Project == "" {
v.Project = DefaultProject
}
if v.Environment == "" {
v.Environment = DefaultEnvironment
}
if err := json.Unmarshal(data, &v); err != nil {
return err
}
*t = Target(v)
return nil
}
func (p *OutboundProfile) UnmarshalJSON(data []byte) error {
type plain OutboundProfile
v := plain(*p)
if v.Project == "" {
v.Project = DefaultProject
}
if v.Environment == "" {
v.Environment = DefaultEnvironment
}
if err := json.Unmarshal(data, &v); err != nil {
return err
}
*p = OutboundProfile(v)
return nil
}
// ResourceScope is metadata shared by independently stored configurations.
type ResourceScope struct {
Project string `json:"project,omitempty"`
+2 -6
View File
@@ -5,7 +5,7 @@ import (
"testing"
)
func TestScopeLegacyDefaultsAndExplicitEmptyRoundTrip(t *testing.T) {
func TestScopeDoesNotInferMissingOwnership(t *testing.T) {
for _, value := range []any{&Asset{}, &Target{}, &Proxy{}, &OutboundProfile{}} {
if err := json.Unmarshal([]byte(`{"name":"legacy"}`), value); err != nil {
t.Fatal(err)
@@ -15,11 +15,7 @@ func TestScopeLegacyDefaultsAndExplicitEmptyRoundTrip(t *testing.T) {
if err := json.Unmarshal(raw, &scope); err != nil {
t.Fatal(err)
}
expectedProject := DefaultProject
if _, asset := value.(*Asset); asset {
expectedProject = ""
}
if scope.Project != expectedProject || scope.Environment != "test" {
if scope.Project != "" || scope.Environment != "" {
t.Fatalf("legacy %T lost default: %s", value, raw)
}
if err := json.Unmarshal([]byte(`{"project":"","environment":""}`), value); err != nil {
+9 -4
View File
@@ -146,14 +146,19 @@ func (t Target) Redacted() (Target, map[string]bool) {
return out, flags
}
func MergeTarget(current Target, patch map[string]any) (Target, error) {
normalizedPatch := normalizeTargetAccountPatch(patch)
if err := exactFields(normalizedPatch, reflect.TypeOf(Target{})); err != nil {
if outbound, ok := patch["outbound"].(map[string]any); ok {
mode, explicit := outbound["mode"].(string)
if !explicit || (mode != "direct" && mode != "k8s") {
return Target{}, errors.New("inline target proxies are not supported; select a shared proxy")
}
}
if err := exactFields(patch, reflect.TypeOf(Target{})); err != nil {
return Target{}, err
}
raw, _ := json.Marshal(current)
var base map[string]any
_ = json.Unmarshal(raw, &base)
merge(base, normalizedPatch)
merge(base, patch)
raw, _ = json.Marshal(base)
var out Target
err := json.Unmarshal(raw, &out)
@@ -161,7 +166,7 @@ func MergeTarget(current Target, patch map[string]any) (Target, error) {
out.TargetCA = ""
}
if err == nil {
err = mergeAccountSecrets(current.Accounts, normalizedPatch, &out)
err = mergeAccountSecrets(current.Accounts, patch, &out)
}
return out, err
}
-80
View File
@@ -1,7 +1,6 @@
package model
import (
"encoding/json"
"errors"
"reflect"
"regexp"
@@ -24,50 +23,6 @@ type TargetAccount struct {
Passphrase string `json:"passphrase,omitempty"`
}
// targetAccountJSON keeps the legacy name input readable without exposing it
// on the domain model or allowing it to remain in newly persisted data.
type targetAccountJSON struct {
ExpiresAt string `json:"expiresAt,omitempty"`
RotationReminderDays int `json:"rotationReminderDays,omitempty"`
ID string `json:"id"`
Remark *string `json:"remark"`
Name string `json:"name,omitempty"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
AuthMethod string `json:"authMethod,omitempty"`
PrivateKey string `json:"privateKey,omitempty"`
PrivateKeyAssetID string `json:"privateKeyAssetId,omitempty"`
CertificateAssetID string `json:"certificateAssetId,omitempty"`
Certificate string `json:"certificate,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
}
func (a *TargetAccount) UnmarshalJSON(data []byte) error {
var wire targetAccountJSON
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
remark := wire.Name
if wire.Remark != nil {
remark = *wire.Remark
}
*a = TargetAccount{
ExpiresAt: wire.ExpiresAt,
RotationReminderDays: wire.RotationReminderDays,
ID: wire.ID,
Remark: remark,
Username: wire.Username,
Password: wire.Password,
AuthMethod: wire.AuthMethod,
PrivateKey: wire.PrivateKey,
PrivateKeyAssetID: wire.PrivateKeyAssetID,
CertificateAssetID: wire.CertificateAssetID,
Certificate: wire.Certificate,
Passphrase: wire.Passphrase,
}
return nil
}
func (a TargetAccount) Apply(p Proxy) Proxy {
if a.AuthMethod == "none" {
a = a.withoutCredentials()
@@ -169,41 +124,6 @@ func (t *Target) ValidateAccounts() error {
return nil
}
// normalizeTargetAccountPatch maps the pre-remark account field to the new
// field before strict field checks and merge logic run. It also prevents a
// legacy name from overriding an explicitly supplied remark.
func normalizeTargetAccountPatch(patch map[string]any) map[string]any {
normalized := make(map[string]any, len(patch))
for key, value := range patch {
normalized[key] = value
}
raw, ok := patch["accounts"].([]any)
if !ok {
return normalized
}
entries := make([]any, len(raw))
for i, entry := range raw {
fields, ok := entry.(map[string]any)
if !ok {
entries[i] = entry
continue
}
copyFields := make(map[string]any, len(fields)+1)
for key, value := range fields {
copyFields[key] = value
}
if _, hasRemark := copyFields["remark"]; !hasRemark {
if legacy, hasName := copyFields["name"]; hasName {
copyFields["remark"] = legacy
}
}
delete(copyFields, "name")
entries[i] = copyFields
}
normalized["accounts"] = entries
return normalized
}
// Omitted credentials retain their previous value by stable account ID; explicit
// empty strings clear credentials. Never match credentials by array position.
func mergeAccountSecrets(current []TargetAccount, patch map[string]any, out *Target) error {
+7 -42
View File
@@ -1,36 +1,10 @@
package model
import (
"encoding/json"
"strings"
"testing"
)
func TestTargetAccountRemarkJSONCompatibility(t *testing.T) {
var legacy TargetAccount
if err := json.Unmarshal([]byte(`{"id":"legacy","name":"旧备注","username":"user"}`), &legacy); err != nil {
t.Fatal(err)
}
if legacy.Remark != "旧备注" {
t.Fatalf("legacy remark = %#v", legacy)
}
raw, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"name"`) || !strings.Contains(string(raw), `"remark":"旧备注"`) {
t.Fatalf("canonical JSON = %s", raw)
}
var explicit TargetAccount
if err = json.Unmarshal([]byte(`{"id":"account","name":"旧值","remark":"新值","username":"user"}`), &explicit); err != nil {
t.Fatal(err)
}
if explicit.Remark != "新值" {
t.Fatalf("explicit remark was overwritten: %#v", explicit)
}
}
func TestTargetAccountRemarkMayBeEmptyAndIsByteLimited(t *testing.T) {
target := TargetDefaults()
target.Name, target.Type, target.TargetHost, target.TargetPort = "target", "mysql", "db.invalid", 3306
@@ -44,22 +18,13 @@ func TestTargetAccountRemarkMayBeEmptyAndIsByteLimited(t *testing.T) {
}
}
func TestMergeTargetNormalizesLegacyAccountName(t *testing.T) {
current := Target{Type: "mysql", Accounts: []TargetAccount{{ID: "account", Remark: "old", Username: "user"}}}
updated, err := MergeTarget(current, map[string]any{"accounts": []any{
map[string]any{"id": "account", "name": "legacy", "username": "user"},
}})
if err != nil {
t.Fatal(err)
func TestMergeTargetRejectsLegacyAccountName(t *testing.T) {
current := Target{Type: "mysql", Accounts: []TargetAccount{{ID: "account", Remark: "current", Username: "user"}}}
if _, err := MergeTarget(current, map[string]any{"accounts": []any{map[string]any{"id": "account", "name": "old", "username": "user"}}}); err == nil {
t.Fatal("legacy account name accepted")
}
if updated.Accounts[0].Remark != "legacy" {
t.Fatalf("remark = %q", updated.Accounts[0].Remark)
}
raw, err := json.Marshal(updated.Accounts[0])
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"name"`) {
t.Fatalf("legacy name persisted: %s", raw)
updated, err := MergeTarget(current, map[string]any{"accounts": []any{map[string]any{"id": "account", "remark": "updated", "username": "user"}}})
if err != nil || updated.Accounts[0].Remark != "updated" {
t.Fatalf("current remark update failed: %v", err)
}
}
+2 -1
View File
@@ -28,10 +28,11 @@ func TestUnauthenticatedTargetAccounts(t *testing.T) {
}
probe := account.Apply(target.Runtime())
probe.ListenPort, probe.GatewayUser, probe.GatewayPassword = 1, "gateway", "gateway-secret"
probe.GatewayAccounts = []GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}
if err := probe.Validate(); err != nil {
t.Fatal(err)
}
probe.GatewayPassword = ""
probe.GatewayAccounts[0].Password = ""
if probe.Validate() == nil {
t.Fatal("anonymous target disabled gateway authentication")
}
+24 -5
View File
@@ -289,21 +289,29 @@ func Merge(current Proxy, patch map[string]any) (Proxy, error) {
// encoding/json otherwise accepts case-insensitive aliases, which could bypass
// server-managed field guards. Configuration patches use exact JSON names only.
func exactFields(patch map[string]any, t reflect.Type) error {
return exactFieldsAt(patch, t, "")
}
func exactFieldsAt(patch map[string]any, t reflect.Type, prefix string) error {
fields := map[string]reflect.Type{}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fields[strings.Split(f.Tag.Get("json"), ",")[0]] = f.Type
name := strings.Split(f.Tag.Get("json"), ",")[0]
if name != "-" && name != "" {
fields[name] = f.Type
}
}
for k, v := range patch {
path := prefix + k
ft, ok := fields[k]
if !ok {
return errors.New("unknown configuration field: " + k)
return &ConfigurationFieldError{Path: path, Reason: "unknown configuration field"}
}
if v == nil {
if ft.Kind() == reflect.Ptr {
continue
}
return errors.New("configuration fields cannot be null")
return &ConfigurationFieldError{Path: path, Reason: "configuration fields cannot be null"}
}
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
@@ -311,12 +319,23 @@ func exactFields(patch map[string]any, t reflect.Type) error {
if ft.Kind() == reflect.Struct {
sub, ok := v.(map[string]any)
if !ok {
return errors.New("configuration object expected")
return &ConfigurationFieldError{Path: path, Reason: "configuration object expected"}
}
if err := exactFields(sub, ft); err != nil {
if err := exactFieldsAt(sub, ft, path+"."); err != nil {
return err
}
}
if ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.Struct {
if items, ok := v.([]any); ok {
for index, item := range items {
if sub, ok := item.(map[string]any); ok {
if err := exactFieldsAt(sub, ft.Elem(), path+"["+strconv.Itoa(index)+"]."); err != nil {
return err
}
}
}
}
}
}
return nil
}
+13 -3
View File
@@ -56,11 +56,16 @@ func validatePreflightKubeconfig(content, contextName string) error {
// SelfSubjectAccessReview API itself, in which case the result is unknown and
// the normal startup path remains authoritative.
func PreflightKubernetes(ctx context.Context, content, contextName, namespace, resource string, port int) (KubernetesPreflight, error) {
result := KubernetesPreflight{Status: "unknown", Checks: []KubernetesPermissionCheck{}}
if port < 1 || port > 65535 {
return result, discoveryError("invalid_port", "Kubernetes 端口必须为 1–65535")
return KubernetesPreflight{Status: "unknown", Checks: []KubernetesPermissionCheck{}}, discoveryError("invalid_port", "Kubernetes 端口必须为 1–65535")
}
o := model.Outbound{Mode: "k8s", KubeConfigContent: content, KubeContext: contextName, Namespace: namespace, Resource: resource}
return TestKubernetes(ctx, o)
}
func TestKubernetes(ctx context.Context, o model.Outbound) (KubernetesPreflight, error) {
result := KubernetesPreflight{Status: "unknown", Checks: []KubernetesPermissionCheck{}}
namespace, resource := o.Namespace, o.Resource
if err := o.ValidateKubernetes(); err != nil {
if errors.Is(err, model.ErrKubeConfigLoginPlugin) {
return result, discoveryError("login_plugin_unsupported", "Kubeconfig 资产不能使用 exec/auth-provider 登录插件;请改用内嵌证书或 Token")
@@ -70,9 +75,11 @@ func PreflightKubernetes(ctx context.Context, content, contextName, namespace, r
}
return result, discoveryError("kubernetes_invalid_config", "Kubernetes 资源、命名空间或 Context 配置无效")
}
if err := validatePreflightKubeconfig(content, contextName); err != nil {
if o.KubeConfigContent != "" {
if err := validatePreflightKubeconfig(o.KubeConfigContent, o.KubeContext); err != nil {
return result, err
}
}
loader, err := kubernetesClientConfig(o)
if err != nil {
return result, discoveryError("kubeconfig_invalid", "无法加载 Kubeconfig,请检查 Context 和凭据")
@@ -81,6 +88,9 @@ func PreflightKubernetes(ctx context.Context, content, contextName, namespace, r
if err != nil {
return result, discoveryError("kubeconfig_invalid", "无法加载 Kubeconfig,请检查 Context 和凭据")
}
if config.ExecProvider != nil || config.AuthProvider != nil {
return result, discoveryError("login_plugin_unsupported", "代理测试不支持 exec/auth-provider 登录插件;请使用内嵌证书或 Token")
}
if namespace == "" {
namespace, _, err = loader.Namespace()
if err != nil {
+45 -36
View File
@@ -25,7 +25,7 @@ func dialSOCKS5(ctx context.Context, address string, o model.Outbound, keepAlive
if err != nil {
return nil, fmt.Errorf("SOCKS5 connection failed: %w", err)
}
done = Begin(ctx, "socks5_negotiation")
done = func(error) {}
defer func() { done(resultErr) }()
good := false
defer func() {
@@ -37,40 +37,9 @@ func dialSOCKS5(ctx context.Context, address string, o model.Outbound, keepAlive
defer stop()
deadline, _ := ctx.Deadline()
_ = c.SetDeadline(deadline)
method := byte(0)
if o.Auth == "password" {
method = 2
if len(o.Username) < 1 || len(o.Username) > 255 || len(o.Password) < 1 || len(o.Password) > 255 {
return nil, errors.New("invalid SOCKS5 credentials")
}
}
if err = write(c, []byte{5, 1, method}); err != nil {
if err = negotiateSOCKS5(ctx, c, o); err != nil {
return nil, err
}
reply := make([]byte, 2)
if _, err = io.ReadFull(c, reply); err != nil {
return nil, err
}
if reply[0] != 5 || reply[1] != method {
return nil, model.AtConnectionStage("protocol_rejected", errors.New("SOCKS5 authentication method rejected"))
}
done(nil)
if method == 2 {
done = Begin(ctx, "socks5_authentication")
req := append([]byte{1, byte(len(o.Username))}, []byte(o.Username)...)
req = append(req, byte(len(o.Password)))
req = append(req, []byte(o.Password)...)
if err = write(c, req); err != nil {
return nil, err
}
if _, err = io.ReadFull(c, reply); err != nil {
return nil, err
}
if reply[0] != 1 || reply[1] != 0 {
return nil, model.AtConnectionStage("socks5_authentication", errors.New("SOCKS5 authentication failed"))
}
done(nil)
}
done = Begin(ctx, "target_connect")
host, port, err := net.SplitHostPort(address)
if err != nil {
@@ -114,11 +83,11 @@ func dialSOCKS5(ctx context.Context, address string, o model.Outbound, keepAlive
case 4:
size = 16
case 3:
var b [1]byte
if _, err = io.ReadFull(c, b[:]); err != nil {
var length [1]byte
if _, err = io.ReadFull(c, length[:]); err != nil {
return nil, err
}
size = int(b[0])
size = int(length[0])
default:
return nil, errors.New("invalid SOCKS5 address")
}
@@ -133,6 +102,46 @@ func dialSOCKS5(ctx context.Context, address string, o model.Outbound, keepAlive
return c, nil
}
func negotiateSOCKS5(ctx context.Context, connection net.Conn, profile model.Outbound) (resultErr error) {
done := Begin(ctx, "socks5_negotiation")
defer func() { done(resultErr) }()
method := byte(0)
if profile.Auth == "password" {
method = 2
if len(profile.Username) < 1 || len(profile.Username) > 255 || len(profile.Password) < 1 || len(profile.Password) > 255 {
return errors.New("invalid SOCKS5 credentials")
}
}
if err := write(connection, []byte{5, 1, method}); err != nil {
return err
}
reply := make([]byte, 2)
if _, err := io.ReadFull(connection, reply); err != nil {
return err
}
if reply[0] != 5 || reply[1] != method {
return model.AtConnectionStage("protocol_rejected", errors.New("SOCKS5 authentication method rejected"))
}
done(nil)
if method == 2 {
done = Begin(ctx, "socks5_authentication")
req := append([]byte{1, byte(len(profile.Username))}, []byte(profile.Username)...)
req = append(req, byte(len(profile.Password)))
req = append(req, []byte(profile.Password)...)
if err := write(connection, req); err != nil {
return err
}
if _, err := io.ReadFull(connection, reply); err != nil {
return err
}
if reply[0] != 1 || reply[1] != 0 {
return model.AtConnectionStage("socks5_authentication", errors.New("SOCKS5 authentication failed"))
}
done(nil)
}
return nil
}
func write(w io.Writer, b []byte) error {
for len(b) > 0 {
n, err := w.Write(b)
@@ -0,0 +1,92 @@
package outbound
import (
"context"
"errors"
"io"
"net"
"testing"
"time"
)
func TestSOCKS5ProxyHandshake(t *testing.T) {
for _, scenario := range []struct {
name string
password, rejected, badVersion bool
}{
{name: "no authentication"},
{name: "password", password: true},
{name: "rejected password", password: true, rejected: true},
{name: "rejected method", rejected: true},
{name: "wrong protocol", badVersion: true},
} {
t.Run(scenario.name, func(t *testing.T) {
method := byte(0)
if scenario.password {
method = 2
}
proxy, finished := mockSOCKS(t, func(connection net.Conn) error {
if err := expectBytes(connection, []byte{5, 1, method}); err != nil {
return err
}
reply := []byte{5, method}
if scenario.badVersion {
reply[0] = 4
}
if scenario.rejected && !scenario.password {
reply[1] = 255
}
if err := write(connection, reply); err != nil {
return err
}
if scenario.password {
if err := expectBytes(connection, []byte{1, 4, 'u', 's', 'e', 'r', 4, 'p', 'a', 's', 's'}); err != nil {
return err
}
status := byte(0)
if scenario.rejected {
status = 1
}
if err := write(connection, []byte{1, status}); err != nil {
return err
}
}
var unexpected [1]byte
if count, err := connection.Read(unexpected[:]); count != 0 || !errors.Is(err, io.EOF) {
return errors.New("probe did not close or attempted a target connection")
}
return nil
})
if scenario.password {
proxy.Outbound.Auth, proxy.Outbound.Username, proxy.Outbound.Password = "password", "user", "pass"
}
err := TestSOCKS5(t.Context(), proxy.Outbound)
if (err != nil) != (scenario.rejected || scenario.badVersion) {
t.Fatalf("unexpected handshake result: %v", err)
}
awaitSOCKS(t, finished)
})
}
}
func TestSOCKS5ProxyHandshakeCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
proxy, finished := mockSOCKS(t, func(connection net.Conn) error {
if err := expectBytes(connection, []byte{5, 1, 0}); err != nil {
return err
}
cancel()
var reply [1]byte
_, err := connection.Read(reply[:])
if !errors.Is(err, io.EOF) {
return err
}
return nil
})
started := time.Now()
if err := TestSOCKS5(ctx, proxy.Outbound); err == nil || time.Since(started) > 2*time.Second {
t.Fatalf("cancellation did not stop handshake: %v", err)
}
awaitSOCKS(t, finished)
}
@@ -0,0 +1,26 @@
package outbound
import (
"context"
"linkport/internal/model"
"net"
"strconv"
"time"
)
func TestSOCKS5(ctx context.Context, profile model.Outbound) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
connection, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(profile.Host, strconv.Itoa(profile.Port)))
if err != nil {
return err
}
defer connection.Close()
stop := context.AfterFunc(ctx, func() { connection.Close() })
defer stop()
deadline, _ := ctx.Deadline()
if err = connection.SetDeadline(deadline); err != nil {
return err
}
return negotiateSOCKS5(ctx, connection, profile)
}
@@ -30,7 +30,7 @@ func TestIdleTimeoutDoesNotInterruptActiveQuery(testContext *testing.T) {
go fakeMongo(ctx, backend, &atomic.Int32{})
return slowReadConn{Conn: client, slow: &slow}, nil
}
proxy := model.Proxy{Type: "mongodb", TargetHost: "fixture", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", ConnectionOptions: model.ConnectionOptions{IdleTimeoutSeconds: 1}}
proxy := model.Proxy{GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "target-secret"}}, Type: "mongodb", TargetHost: "fixture", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", ConnectionOptions: model.ConnectionOptions{IdleTimeoutSeconds: 1}}
server, client := net.Pipe()
defer client.Close()
done := make(chan error, 1)
+1 -1
View File
@@ -31,7 +31,7 @@ func TestOfficialDriver(t *testing.T) {
go fakeMongo(ctx, b, &calls)
return a, nil
}
p := model.Proxy{TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p := model.Proxy{Type: "mongodb", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "target-secret"}}, TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}
p.AccessMode = ""
listener, e := net.Listen("tcp", "127.0.0.1:0")
+1 -1
View File
@@ -126,7 +126,7 @@ func TestGatewayTLSAuthenticationForwardingAndReadonly(t *testing.T) {
go fakeMongo(ctx, b, &calls, true)
return a, nil
}
p := model.Proxy{TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p := model.Proxy{Type: "mongodb", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "target-secret"}}, TargetHost: "target.invalid", TargetPort: 27017, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p.TargetCompression = "zlib"
server, client := net.Pipe()
done := make(chan error, 1)
@@ -36,7 +36,7 @@ func TestRealMongo(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
serverTLS, roots := testTLS(t)
p := model.Proxy{TargetHost: host, TargetPort: port, TargetUser: os.Getenv("LINKPORT_MONGO_TEST_USER"), TargetPassword: os.Getenv("LINKPORT_MONGO_TEST_PASSWORD"), AuthSource: "admin", GatewayUser: "gateway", GatewayPassword: "gateway-test-secret", TargetCA: os.Getenv("LINKPORT_MONGO_TEST_CA")}
p := model.Proxy{Type: "mongodb", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-test-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: os.Getenv("LINKPORT_MONGO_TEST_USER"), Password: os.Getenv("LINKPORT_MONGO_TEST_PASSWORD")}}, TargetHost: host, TargetPort: port, TargetUser: os.Getenv("LINKPORT_MONGO_TEST_USER"), TargetPassword: os.Getenv("LINKPORT_MONGO_TEST_PASSWORD"), AuthSource: "admin", GatewayUser: "gateway", GatewayPassword: "gateway-test-secret", TargetCA: os.Getenv("LINKPORT_MONGO_TEST_CA")}
dial := testutil.FixtureDial(t, &p)
p.TargetTLS = p.TargetCA != ""
info, e := New().Test(ctx, p, dial)
+1 -1
View File
@@ -94,7 +94,7 @@ func TestAnonymousMongoStillAuthenticatesGatewayClients(t *testing.T) {
}
client, server := net.Pipe()
finished := make(chan error, 1)
proxy := model.Proxy{Type: "mongodb", TargetHost: "fixture.invalid", TargetPort: 27017, GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: "readwrite"}
proxy := model.Proxy{GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret", AccessMode: "readwrite"}}, Type: "mongodb", TargetHost: "fixture.invalid", TargetPort: 27017, GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: "readwrite"}
go func() { finished <- New().Serve(ctx, server, proxy, dial, nil, nil) }()
connection, err := connect(ctx, model.Proxy{TargetHost: "fixture.invalid", TargetPort: 27018, TargetUser: "gateway", TargetPassword: password}, func(context.Context, string, string) (net.Conn, error) { return client, nil })
if password == "wrong" {
@@ -143,7 +143,7 @@ func ansiGatewayFixture(t *testing.T, mode, failSQL string) (string, <-chan ansi
t.Cleanup(func() { _ = gateway.Close() })
host, port, _ := net.SplitHostPort(backend.Addr().String())
pn, _ := strconv.Atoi(port)
p := model.Proxy{TargetHost: host, TargetPort: pn, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p := model.Proxy{Type: "mysql", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "secret"}}, TargetHost: host, TargetPort: pn, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
go func() {
raw, err := gateway.Accept()
if err == nil {
+1 -1
View File
@@ -87,7 +87,7 @@ func binlogGatewayFixture(t *testing.T, mode string, responses [][]byte, audit m
t.Cleanup(func() { _ = gateway.Close() })
host, port, _ := net.SplitHostPort(backend.Addr().String())
portNumber, _ := strconv.Atoi(port)
p := model.Proxy{TargetHost: host, TargetPort: portNumber, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p := model.Proxy{Type: "mysql", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "secret"}}, TargetHost: host, TargetPort: portNumber, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
serveDone := make(chan error, 1)
go func() {
raw, err := gateway.Accept()
+3 -1
View File
@@ -116,9 +116,11 @@ func testGatewayTLS(t *testing.T, mode string, certificate bool, audit ...model.
t.Cleanup(func() { gateway.Close() })
host, port, _ := net.SplitHostPort(backend.Addr().String())
pn, _ := strconv.Atoi(port)
p := model.Proxy{TargetHost: host, TargetPort: pn, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p := model.Proxy{Type: "mysql", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "secret"}}, TargetHost: host, TargetPort: pn, TargetUser: "target", TargetPassword: "secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: mode}
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret", AccessMode: mode}}
p.AccessMode = ""
p.TargetAccountID = "upstream"
p.TargetAccounts = []model.TargetAccount{{ID: "upstream", Username: "target", Password: "secret"}}
tc, cc := testTLS(t)
if !certificate {
tc = nil
@@ -33,7 +33,7 @@ func TestRealMySQL(t *testing.T) {
if e != nil {
t.Fatal(e)
}
p := model.Proxy{TargetHost: host, TargetPort: pn, TargetUser: os.Getenv("LINKPORT_MYSQL_TEST_USER"), TargetPassword: os.Getenv("LINKPORT_MYSQL_TEST_PASSWORD"), GatewayUser: "integration", GatewayPassword: "integration-password"}
p := model.Proxy{Type: "mysql", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "integration", Password: "integration-password"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: os.Getenv("LINKPORT_MYSQL_TEST_USER"), Password: os.Getenv("LINKPORT_MYSQL_TEST_PASSWORD")}}, TargetHost: host, TargetPort: pn, TargetUser: os.Getenv("LINKPORT_MYSQL_TEST_USER"), TargetPassword: os.Getenv("LINKPORT_MYSQL_TEST_PASSWORD"), GatewayUser: "integration", GatewayPassword: "integration-password"}
p.TargetCompression = os.Getenv("LINKPORT_MYSQL_TEST_COMPRESSION")
var targetTLS *tls.Config
if path := os.Getenv("LINKPORT_MYSQL_TEST_CA"); path != "" {
@@ -0,0 +1,21 @@
package mysqlproxy
import "testing"
func TestSyncdbJSONReadOnly(t *testing.T) {
allowed := []string{
"SELECT JSON_ARRAYAGG(JSON_OBJECT(?, sync_rows.id, ?, CAST(sync_rows.created AS CHAR))), MAX(sync_rows.id) FROM (SELECT * FROM t WHERE id > ? ORDER BY id LIMIT ?) AS sync_rows",
"SELECT JSON_ARRAYAGG(JSON_OBJECT('blob',HEX(payload),'bit',LPAD(HEX(bits),4,'0'))) FROM t",
}
for _, sql := range allowed {
if err := CheckReadOnly(sql); err != nil {
t.Fatalf("safe JSON read rejected: %v", err)
}
}
denied := []string{"SELECT JSON_ARRAYAGG(JSON_OBJECT('value',SLEEP(1))) FROM t", "SELECT JSON_ARRAYAGG(JSON_OBJECT('file',LOAD_FILE('/tmp/x'))) FROM t", "SELECT JSON_ARRAYAGG(JSON_OBJECT('data',db.custom())) FROM t", "SELECT JSON_ARRAYAGG(JSON_OBJECT('data',id)) FROM t FOR UPDATE", "SELECT JSON_ARRAYAGG(JSON_OBJECT('data',id)) INTO OUTFILE '/tmp/x' FROM t", "SELECT JSON_ARRAYAGG(JSON_OBJECT('data',@secret)) FROM t"}
for _, sql := range denied {
if err := CheckReadOnly(sql); err == nil {
t.Fatalf("unsafe JSON expression accepted: %s", sql)
}
}
}
+2 -1
View File
@@ -7,7 +7,7 @@ import (
)
var errReadOnly = errors.New("request denied by read-only policy")
var functions = wordSet("count sum avg min max abs ceil ceiling floor round coalesce ifnull nullif if concat concat_ws length char_length lower upper substring trim replace cast convert date year month day now current_timestamp database schema version user current_user")
var functions = wordSet("count sum avg min max abs ceil ceiling floor round coalesce ifnull nullif if concat concat_ws length char_length lower upper substring trim replace cast convert date year month day now current_timestamp database schema version user current_user hex unhex lpad")
func wordSet(s string) map[string]bool {
m := map[string]bool{}
@@ -83,6 +83,7 @@ func checkReadOnlyStatement(query string) error {
switch n.(type) {
case *sqlparser.AndExpr, *sqlparser.OrExpr, *sqlparser.XorExpr, *sqlparser.NotExpr, *sqlparser.ComparisonExpr, *sqlparser.BetweenExpr, *sqlparser.IsExpr, *sqlparser.ExistsExpr, *sqlparser.Literal, *sqlparser.Argument, *sqlparser.NullVal, sqlparser.BoolVal, *sqlparser.ColName, sqlparser.ValTuple, *sqlparser.Subquery, sqlparser.ListArg, *sqlparser.BinaryExpr, *sqlparser.UnaryExpr, *sqlparser.IntroducerExpr, *sqlparser.CollateExpr, *sqlparser.CaseExpr, *sqlparser.CastExpr, *sqlparser.Default:
case *sqlparser.Count, *sqlparser.CountStar, *sqlparser.Sum, *sqlparser.Avg, *sqlparser.Min, *sqlparser.Max, *sqlparser.ConvertExpr, *sqlparser.ConvertUsingExpr, *sqlparser.SubstrExpr:
case *sqlparser.JSONObjectExpr, *sqlparser.JSONArrayAgg:
case *sqlparser.TrimFuncExpr, *sqlparser.CurTimeFuncExpr:
s := strings.ToLower(sqlparser.String(n))
i := strings.IndexByte(s, '(')
+1 -1
View File
@@ -86,7 +86,7 @@ func TestPostgresAuthenticationSimpleExtendedAndAudit(t *testing.T) {
t.Fatal(err)
}
defer listener.Close()
p := model.Proxy{TargetHost: "pg.internal", TargetPort: 5432, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AuthSource: "postgres"}
p := model.Proxy{Type: "pgsql", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "target-secret"}}, TargetHost: "pg.internal", TargetPort: 5432, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret", AuthSource: "postgres"}
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret"}}
var mu sync.Mutex
var records []model.Command
+1 -1
View File
@@ -82,7 +82,7 @@ func TestAMQPClientAuthenticationPublishAndAudit(t *testing.T) {
t.Fatal(err)
}
defer listener.Close()
p := model.Proxy{TargetHost: "broker.internal", TargetPort: 5672, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret"}
p := model.Proxy{Type: "rabbitmq", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "target-secret"}}, TargetHost: "broker.internal", TargetPort: 5672, TargetUser: "target", TargetPassword: "target-secret", GatewayUser: "gateway", GatewayPassword: "gateway-secret"}
p.GatewayAccounts = []model.GatewayAccount{{ID: "other", Username: "other", Password: "other-secret"}, {ID: "gateway", Username: "gateway", Password: "gateway-secret"}}
var mu sync.Mutex
var audits []model.Command
@@ -157,6 +157,8 @@ func TestClusterHelloRewritesPasswordOnlyTargetAuthentication(t *testing.T) {
TargetHost: "redis.example",
TargetPort: 6379,
TargetPassword: "target-secret",
TargetAccountID: "upstream",
TargetAccounts: []model.TargetAccount{{ID: "upstream", Password: "target-secret"}},
Cluster: model.ClusterConfig{Mode: "redis-cluster"},
GatewayAccounts: tc.gatewayAccounts,
}
+3 -3
View File
@@ -17,7 +17,7 @@ import (
"time"
)
var fixture = model.Proxy{GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: "redis.example", TargetPort: 6379, TargetUser: "target", TargetPassword: "target-secret"}
var fixture = model.Proxy{Type: "redis", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "target-secret"}}, GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: "redis.example", TargetPort: 6379, TargetUser: "target", TargetPassword: "target-secret"}
// Use real sockets for the gateway client: unlike net.Pipe, TCP permits the
// write-ahead behavior used by pipelining and streaming subscription clients.
@@ -206,7 +206,7 @@ func TestAuditFailurePreventsForwarding(t *testing.T) {
return c, nil
}
c, done := gateway(t, ctx, fixture, dial)
writeCommand(t, c, "AUTH", fixture.GatewayPassword)
writeCommand(t, c, "AUTH", fixture.GatewayUser, fixture.GatewayPassword)
readBytes(t, c, "+OK\r\n")
writeCommand(t, c, "SET", "key", "value")
if err := waitDone(t, done); err == nil || !strings.Contains(err.Error(), "audit") {
@@ -328,7 +328,7 @@ func TestCancelClosesIdleClientAndUpstream(t *testing.T) {
return c, nil
}
c, done := gateway(t, ctx, fixture, dial)
writeCommand(t, c, "AUTH", fixture.GatewayPassword)
writeCommand(t, c, "AUTH", fixture.GatewayUser, fixture.GatewayPassword)
readBytes(t, c, "+OK\r\n")
cancel()
waitDone(t, done)
@@ -33,7 +33,7 @@ func TestRealSSH(t *testing.T) {
t.Fatal(err)
}
hostKey, signer := testSigner(t)
p := model.Proxy{TargetHost: host, TargetPort: n, TargetUser: os.Getenv("LINKPORT_SSH_TEST_USER"), TargetPassword: os.Getenv("LINKPORT_SSH_TEST_PASSWORD"), GatewayUser: "integration", GatewayPassword: "disposable-gateway-password", SSH: model.SSHConfig{AuthMethod: "password", HostKey: hostKey}}
p := model.Proxy{Type: "ssh", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "integration", Password: "disposable-gateway-password"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: os.Getenv("LINKPORT_SSH_TEST_USER"), Password: os.Getenv("LINKPORT_SSH_TEST_PASSWORD")}}, TargetHost: host, TargetPort: n, TargetUser: os.Getenv("LINKPORT_SSH_TEST_USER"), TargetPassword: os.Getenv("LINKPORT_SSH_TEST_PASSWORD"), GatewayUser: "integration", GatewayPassword: "disposable-gateway-password", SSH: model.SSHConfig{AuthMethod: "password", HostKey: hostKey}}
dial := testutil.FixtureDial(t, &p)
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
+1 -1
View File
@@ -123,7 +123,7 @@ func testProxy(t *testing.T, address string, signer ssh.Signer) model.Proxy {
key, _ := testSigner(t)
host, port, _ := net.SplitHostPort(address)
n, _ := strconv.Atoi(port)
return model.Proxy{GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: host, TargetPort: n, TargetUser: "target", TargetPassword: "secret", SSH: model.SSHConfig{HostKey: key, TargetHostKey: string(ssh.MarshalAuthorizedKey(signer.PublicKey())), AuthMethod: "password"}}
return model.Proxy{Type: "ssh", GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "gateway", Password: "gateway-secret"}}, TargetAccountID: "upstream", TargetAccounts: []model.TargetAccount{{ID: "upstream", Username: "target", Password: "secret"}}, GatewayUser: "gateway", GatewayPassword: "gateway-secret", TargetHost: host, TargetPort: n, TargetUser: "target", TargetPassword: "secret", SSH: model.SSHConfig{HostKey: key, TargetHostKey: string(ssh.MarshalAuthorizedKey(signer.PublicKey())), AuthMethod: "password"}}
}
func direct(ctx context.Context, n, a string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, n, a)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func TestAccountRestrictions(t *testing.T) {
p := model.Proxy{GatewayUser: "legacy", GatewayPassword: "secret", AllowCIDRs: []string{"127.0.0.0/8"}, GatewayAccounts: []model.GatewayAccount{
p := model.Proxy{Type: "mongodb", GatewayUser: "legacy", GatewayPassword: "secret", AllowCIDRs: []string{"127.0.0.0/8"}, GatewayAccounts: []model.GatewayAccount{
{ID: "one", Username: "first", Password: "first-secret"},
{ID: "two", Username: "second", Password: "second-secret", AllowCIDRs: []string{"127.0.0.1"}, AccessMode: "readonly"},
{ID: "off", Username: "disabled", Password: "secret", Disabled: true},
+8 -8
View File
@@ -9,8 +9,8 @@ import (
"time"
)
func (s *Store) GetAsset(id string) (model.KubeconfigAsset, error) {
var a model.KubeconfigAsset
func (s *Store) GetAsset(id string) (model.Asset, error) {
var a model.Asset
var blob []byte
err := s.db.QueryRow("SELECT config FROM assets WHERE id=?", id).Scan(&blob)
if err == nil {
@@ -20,17 +20,17 @@ func (s *Store) GetAsset(id string) (model.KubeconfigAsset, error) {
return a, err
}
func (s *Store) Assets() ([]model.KubeconfigAsset, error) {
func (s *Store) Assets() ([]model.Asset, error) {
rows, err := s.db.Query("SELECT id,config FROM assets ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
result := []model.KubeconfigAsset{}
result := []model.Asset{}
for rows.Next() {
var id string
var blob []byte
var a model.KubeconfigAsset
var a model.Asset
if err = rows.Scan(&id, &blob); err != nil {
return nil, err
}
@@ -43,7 +43,7 @@ func (s *Store) Assets() ([]model.KubeconfigAsset, error) {
return result, rows.Err()
}
func (s *Store) saveAsset(tx *sql.Tx, a model.KubeconfigAsset) error {
func (s *Store) saveAsset(tx *sql.Tx, a model.Asset) error {
return s.saveAssetChecked(tx, a, true)
}
@@ -63,7 +63,7 @@ func (s *Store) saveAssetChecked(tx *sql.Tx, a model.Asset, checkDependents bool
if oldErr != nil && !errors.Is(oldErr, sql.ErrNoRows) {
return oldErr
}
var old model.KubeconfigAsset
var old model.Asset
if oldErr == nil {
if err := s.unseal(oldBlob, "asset:"+a.ID, &old); err != nil {
return err
@@ -91,7 +91,7 @@ func (s *Store) saveAssetChecked(tx *sql.Tx, a model.Asset, checkDependents bool
return err
}
func (s *Store) SaveAsset(a model.KubeconfigAsset) error {
func (s *Store) SaveAsset(a model.Asset) error {
old, oldErr := s.GetAsset(a.ID)
if oldErr != nil && !errors.Is(oldErr, sql.ErrNoRows) {
return oldErr
+6 -6
View File
@@ -9,8 +9,8 @@ import (
"testing"
)
func backupAsset() model.KubeconfigAsset {
return model.KubeconfigAsset{ID: "cluster-asset", Name: "cluster", Type: "kubeconfig", Content: `apiVersion: v1
func backupAsset() model.Asset {
return model.Asset{ID: "cluster-asset", Name: "cluster", Type: "kubeconfig", Content: `apiVersion: v1
kind: Config
current-context: test
clusters:
@@ -33,7 +33,7 @@ func TestAssetBackupRoundTripAndRedaction(t *testing.T) {
s := configStore(t)
a := backupAsset()
o := model.OutboundProfile{ID: "kube", Name: "kube", Outbound: model.Outbound{Mode: "k8s", Resource: "pod/db", KubeConfigAssetID: a.ID}}
in := Bundle{Version: 1, Assets: []model.KubeconfigAsset{a}, Outbounds: []model.OutboundProfile{o}}
in := Bundle{Version: 2, Assets: []model.Asset{a}, Outbounds: []model.OutboundProfile{o}}
if err := s.RestoreBundle(in); err != nil {
t.Fatal(err)
}
@@ -66,11 +66,11 @@ func TestAssetBackupRoundTripAndRedaction(t *testing.T) {
func TestAssetImportPreviewHistoryAndRevisionGuard(t *testing.T) {
s := configStore(t)
a := backupAsset()
if err := s.RestoreBundle(Bundle{Version: 1, Assets: []model.KubeconfigAsset{a}}); err != nil {
if err := s.RestoreBundle(Bundle{Version: 2, Assets: []model.Asset{a}}); err != nil {
t.Fatal(err)
}
a.Content = strings.ReplaceAll(a.Content, "asset-private-token", "replacement-private-token")
in := Bundle{Version: 1, Assets: []model.KubeconfigAsset{a}}
in := Bundle{Version: 2, Assets: []model.Asset{a}}
preview, err := s.PreviewBundle(in)
if err != nil || preview.Assets != 1 || len(preview.Resources) != 1 {
t.Fatal("asset preview missing", err)
@@ -102,7 +102,7 @@ func TestAssetImportRejectsBrokenReferencesAtomically(t *testing.T) {
s := configStore(t)
a := backupAsset()
o := model.OutboundProfile{ID: "kube", Name: "kube", Outbound: model.Outbound{Mode: "k8s", Resource: "pod/db", KubeConfigAssetID: "missing"}}
in := Bundle{Version: 1, Assets: []model.KubeconfigAsset{a}, Outbounds: []model.OutboundProfile{o}}
in := Bundle{Version: 2, Assets: []model.Asset{a}, Outbounds: []model.OutboundProfile{o}}
if _, err := s.PreviewBundle(in); err == nil {
t.Fatal("preview accepted missing asset")
}
+1 -1
View File
@@ -38,7 +38,7 @@ users:
user:
token: runtime-private-token
`
asset := model.KubeconfigAsset{ID: "asset", Name: "asset", Type: "kubeconfig", Content: content}
asset := model.Asset{ID: "asset", Name: "asset", Type: "kubeconfig", Content: content}
if err := s.SaveAsset(asset); err != nil {
t.Fatal(err)
}
+184
View File
@@ -0,0 +1,184 @@
package store
import (
"bytes"
"linkport/internal/model"
"linkport/internal/security"
"log"
"strings"
"time"
)
const auditQueueRecords = 8192
const auditQueueBytes = 64 << 20
type queuedAudit struct {
proxy model.Proxy
source string
command model.Command
output *model.CommandOutput
at time.Time
size int
}
type AuditQueueStats struct {
Pending int `json:"pending"`
PendingBytes int `json:"pendingBytes"`
Written uint64 `json:"written"`
Failed uint64 `json:"failed"`
Dropped uint64 `json:"dropped"`
}
// EnqueueCommand returns without disk I/O; gateway forwarding does not await audit persistence.
func (s *Store) EnqueueCommand(p model.Proxy, source string, command model.Command) error {
at := time.Now().UTC()
if hasCommandResult(p.Type, command.Kind) && !command.RecordedAt.IsZero() {
at = command.RecordedAt
}
s.enqueueAudit(queuedAudit{proxy: p, source: source, command: command, at: at, size: len(command.Text) + len(command.Keys) + len(source) + 512})
return nil
}
// EnqueueCommandOutput owns a copy of mutable protocol buffers before returning.
func (s *Store) EnqueueCommandOutput(p model.Proxy, source string, output model.CommandOutput) error {
s.enqueueAudit(queuedAudit{proxy: p, source: source, output: &output, size: len(output.Data) + len(source) + 512})
return nil
}
// enqueueAudit bounds retained memory; overflow is observable but cannot block forwarding.
func (s *Store) enqueueAudit(job queuedAudit) {
s.asyncMu.Lock()
defer s.asyncMu.Unlock()
if s.asyncClosed || len(s.asyncJobs) >= auditQueueRecords || job.size > auditQueueBytes-s.asyncBytes {
s.asyncDropped++
return
}
job.proxy = model.Proxy{ID: job.proxy.ID, TargetID: job.proxy.TargetID, Type: job.proxy.Type}
job.command.Text = strings.Clone(job.command.Text)
job.command.Keys = strings.Clone(job.command.Keys)
if job.output != nil {
job.output.Data = bytes.Clone(job.output.Data)
if job.output.Result.ExitCode != nil {
v := *job.output.Result.ExitCode
job.output.Result.ExitCode = &v
}
if job.output.Result.DurationMS != nil {
v := *job.output.Result.DurationMS
job.output.Result.DurationMS = &v
}
}
s.asyncJobs = append(s.asyncJobs, job)
s.asyncBytes += job.size
if s.asyncDone == nil {
s.asyncDone = make(chan struct{})
go s.consumeAudits()
}
}
// consumeAudits preserves FIFO across commands, outputs and SSH record updates.
func (s *Store) consumeAudits() {
for {
s.asyncMu.Lock()
if len(s.asyncJobs) == 0 {
close(s.asyncDone)
s.asyncDone = nil
s.asyncMu.Unlock()
return
}
n := 1
first := s.asyncJobs[0]
total := first.size
if first.output == nil && !first.command.Update {
for n < len(s.asyncJobs) && n < 64 {
next := s.asyncJobs[n]
if next.output != nil || next.command.Update || next.at.UTC().Format("2006-01-02") != first.at.UTC().Format("2006-01-02") || total+next.size > 1<<20 {
break
}
total += next.size
n++
}
}
batch := append([]queuedAudit(nil), s.asyncJobs[:n]...)
clear(s.asyncJobs[:n])
s.asyncJobs = s.asyncJobs[n:]
s.asyncBytes -= total
s.asyncMu.Unlock()
var err error
if first.output != nil {
err = s.RecordCommandOutput(first.proxy, first.source, *first.output)
} else if first.command.Update {
err = s.recordCommandAt(first.proxy, first.source, first.command, first.at)
} else {
err = s.writeQueuedCommands(batch)
}
s.asyncMu.Lock()
if err != nil {
previous := s.asyncFailed
s.asyncFailed += uint64(n)
if previous == 0 || s.asyncFailed/64 != previous/64 {
log.Printf("audit persistence failed records=%d error_type=%T", s.asyncFailed, err)
}
} else {
s.asyncWritten += uint64(n)
}
s.asyncMu.Unlock()
}
}
// writeQueuedCommands amortizes durable commits while retaining encrypted records and shard rotation.
func (s *Store) writeQueuedCommands(batch []queuedAudit) error {
args := make([][]any, 0, len(batch))
var estimate int64
for _, j := range batch {
id := security.Token()
if hasCommandResult(j.proxy.Type, j.command.Kind) && j.command.AuditID != "" {
id = j.command.AuditID
}
blob, err := s.seal(j.command, "command:"+id)
if err != nil {
return err
}
estimate += int64(len(blob)) + 1024
args = append(args, []any{id, j.proxy.ID, j.proxy.TargetID, j.source, j.proxy.Type, j.command.Kind, j.command.Decision, blob, j.at.UTC().Format(time.RFC3339Nano)})
}
s.auditOps.Lock()
defer s.auditOps.Unlock()
db, err := s.auditDBForWrite(batch[0].at.UTC().Format("2006-01-02"), estimate)
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)")
if err != nil {
return err
}
defer stmt.Close()
for _, a := range args {
if _, err = stmt.Exec(a...); err != nil {
return err
}
}
return tx.Commit()
}
// AuditQueueSnapshot exposes pending work, disk failures and overload loss separately.
func (s *Store) AuditQueueSnapshot() AuditQueueStats {
s.asyncMu.Lock()
defer s.asyncMu.Unlock()
return AuditQueueStats{Pending: len(s.asyncJobs), PendingBytes: s.asyncBytes, Written: s.asyncWritten, Failed: s.asyncFailed, Dropped: s.asyncDropped}
}
// drainAudits rejects new entries and waits for accepted records before SQLite closes.
func (s *Store) drainAudits() {
s.asyncMu.Lock()
s.asyncClosed = true
done := s.asyncDone
s.asyncMu.Unlock()
if done != nil {
<-done
}
}
+144
View File
@@ -0,0 +1,144 @@
package store
import (
"linkport/internal/model"
"testing"
"time"
)
func waitAuditConsumer(t testing.TB, s *Store) {
t.Helper()
s.asyncMu.Lock()
done := s.asyncDone
s.asyncMu.Unlock()
if done != nil {
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("audit consumer did not drain")
}
}
}
func TestAsyncAuditDoesNotWaitForDiskAndPreservesOutput(t *testing.T) {
s, _, _ := shardFixture(t)
p := model.Proxy{ID: "async-fixture", Type: "mysql"}
at := time.Now().UTC()
s.auditOps.Lock()
unlocked := false
defer func() {
if !unlocked {
s.auditOps.Unlock()
}
}()
done := make(chan struct{})
data := []byte("original")
go func() {
s.EnqueueCommand(p, "fixture", model.Command{AuditID: "ordered", RecordedAt: at, Kind: "query", Text: "SELECT 1", Decision: "allowed"})
s.EnqueueCommandOutput(p, "fixture", model.CommandOutput{CommandID: "ordered", RecordedAt: at, Stream: "result", Data: data})
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("forwarding blocked by audit disk lock")
}
copy(data, []byte("modified"))
s.auditOps.Unlock()
unlocked = true
waitAuditConsumer(t, s)
stats := s.AuditQueueSnapshot()
if stats.Written != 2 || stats.Failed != 0 {
t.Fatalf("unordered persistence: %+v", stats)
}
db, err := s.auditDB(at.Format("2006-01-02"))
if err != nil {
t.Fatal(err)
}
var id, stream string
var blob []byte
if err := db.QueryRow("SELECT id,stream,content FROM command_outputs WHERE command_id='ordered'").Scan(&id, &stream, &blob); err != nil {
t.Fatal(err)
}
plain, err := s.vault.Open(blob, outputPurpose("ordered", id, stream))
if err != nil {
t.Fatal(err)
}
if string(plain) != "original" {
t.Fatalf("caller buffer reused: %q", plain)
}
}
func TestAsyncAuditCloseDrainsAcceptedRecords(t *testing.T) {
s, _, _ := shardFixture(t)
for i := 0; i < 100; i++ {
s.EnqueueCommand(model.Proxy{Type: "redis"}, "fixture", model.Command{Kind: "ping", Text: "PING"})
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
stats := s.AuditQueueSnapshot()
if stats.Written != 100 || stats.Pending != 0 || stats.Failed != 0 {
t.Fatalf("close lost accepted work: %+v", stats)
}
if err := s.EnqueueCommand(model.Proxy{Type: "redis"}, "fixture", model.Command{}); err != nil {
t.Fatal("forwarding received persistence error", err)
}
if s.AuditQueueSnapshot().Dropped != 1 {
t.Fatal("closed queue loss not counted")
}
}
func TestAsyncAuditOverflowDoesNotBlock(t *testing.T) {
s, _, _ := shardFixture(t)
s.asyncMu.Lock()
s.asyncBytes = auditQueueBytes
s.asyncMu.Unlock()
if err := s.EnqueueCommand(model.Proxy{Type: "redis"}, "fixture", model.Command{}); err != nil {
t.Fatal(err)
}
if s.AuditQueueSnapshot().Dropped != 1 {
t.Fatal("overflow not counted")
}
s.asyncMu.Lock()
s.asyncBytes = 0
s.asyncMu.Unlock()
}
func TestAsyncAuditDiskFailureDoesNotReachCaller(t *testing.T) {
s, _, _ := shardFixture(t)
// A duplicate ID causes the queued transaction to fail, without forwarding failure.
p := model.Proxy{Type: "mysql"}
at := time.Now().UTC()
c := model.Command{AuditID: "duplicate", RecordedAt: at, Kind: "query", Text: "SELECT 1"}
if err := s.RecordCommand(p, "fixture", c); err != nil {
t.Fatal(err)
}
if err := s.EnqueueCommand(p, "fixture", c); err != nil {
t.Fatal(err)
}
waitAuditConsumer(t, s)
if s.AuditQueueSnapshot().Failed != 1 {
t.Fatal("disk failure not counted")
}
}
// Includes draining disk work, so benchmark throughput cannot hide a growing queue.
func BenchmarkAsyncCommandAudit(b *testing.B) {
s := benchmarkAuditStore(b)
p := model.Proxy{Type: "redis"}
c := model.Command{Kind: "ping", Text: "PING", Decision: "allowed"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.EnqueueCommand(p, "fixture", c)
if i%256 == 255 {
waitAuditConsumer(b, s)
}
}
waitAuditConsumer(b, s)
b.StopTimer()
stats := s.AuditQueueSnapshot()
if stats.Written != uint64(b.N) || stats.Dropped != 0 || stats.Failed != 0 {
b.Fatalf("incomplete audit benchmark: %+v", stats)
}
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"encoding/base64"
"fmt"
"linkport/internal/model"
"linkport/internal/security"
"path/filepath"
"testing"
"time"
)
func benchmarkAuditStore(b *testing.B) *Store {
b.Helper()
vault, err := security.NewVault(base64.StdEncoding.EncodeToString(make([]byte, 32)))
if err != nil {
b.Fatal(err)
}
database, err := Open(filepath.Join(b.TempDir(), "config.sqlite"), vault)
if err != nil {
b.Fatal(err)
}
b.Cleanup(func() { database.Close() })
return database
}
func BenchmarkCommandAudit(b *testing.B) {
for _, parallel := range []bool{false, true} {
b.Run(fmt.Sprintf("parallel=%v", parallel), func(b *testing.B) {
database := benchmarkAuditStore(b)
proxy := model.Proxy{ID: "fixture", Type: "redis"}
command := model.Command{Kind: "ping", Text: `["PING"]`, Decision: "allowed"}
if err := database.RecordCommand(proxy, "fixture", command); err != nil {
b.Fatal(err)
}
b.ResetTimer()
if parallel {
b.RunParallel(func(worker *testing.PB) {
for worker.Next() {
if err := database.RecordCommand(proxy, "fixture", command); err != nil {
b.Error(err)
return
}
}
})
} else {
for iteration := 0; iteration < b.N; iteration++ {
if err := database.RecordCommand(proxy, "fixture", command); err != nil {
b.Fatal(err)
}
}
}
})
}
}
func BenchmarkCommandOutputAudit(b *testing.B) {
for _, shards := range []int{1, 64} {
b.Run(fmt.Sprintf("shards=%d", shards), func(b *testing.B) {
database := benchmarkAuditStore(b)
when := time.Now().UTC()
for day := 1; day < shards; day++ {
if _, err := database.auditDB(when.AddDate(0, 0, -day).Format("2006-01-02")); err != nil {
b.Fatal(err)
}
}
proxy := model.Proxy{ID: "fixture", Type: "mysql"}
command := model.Command{AuditID: "fixture-query", RecordedAt: when, Kind: "query", Text: "SELECT 1", Decision: "allowed"}
if err := database.RecordCommand(proxy, "fixture", command); err != nil {
b.Fatal(err)
}
output := model.CommandOutput{CommandID: command.AuditID, RecordedAt: when, Stream: "result", Data: []byte("fixture-row\n")}
b.ResetTimer()
for iteration := 0; iteration < b.N; iteration++ {
if err := database.RecordCommandOutput(proxy, "fixture", output); err != nil {
b.Fatal(err)
}
}
})
}
}
-111
View File
@@ -30,8 +30,6 @@ CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT,proxy_id
CREATE INDEX IF NOT EXISTS events_at ON events(at);
CREATE INDEX IF NOT EXISTS events_proxy ON events(proxy_id);`
func (s *Store) usesShardedAudit() bool { return s.shardedAudit }
func auditShardDate(key string) (time.Time, error) {
base := key
if index := strings.Index(base, ".part"); index >= 0 {
@@ -99,9 +97,6 @@ func (s *Store) auditDB(day string) (*sql.DB, error) {
if s.closed {
return nil, errors.New("store is closed")
}
if s.portable && !s.shardedAudit {
return s.db, nil
}
return s.openAuditDBLocked(day)
}
@@ -129,9 +124,6 @@ func (s *Store) openAuditDBLocked(day string) (*sql.DB, error) {
// configured limit. A single record may exceed the limit; it is never split
// across databases, so command/output writes remain atomic.
func (s *Store) auditDBForWrite(day string, estimate int64) (*sql.DB, error) {
if !s.shardedAudit {
return s.auditDB(day)
}
if _, err := auditShardDate(day); err != nil {
return nil, err
}
@@ -247,13 +239,6 @@ func formatAuditPart(part int) string {
}
func (s *Store) auditDays() ([]string, error) {
if s.portable && !s.shardedAudit {
return []string{"1970-01-01"}, nil
}
return s.legacyAuditDays()
}
func (s *Store) legacyAuditDays() ([]string, error) {
entries, err := os.ReadDir(s.auditDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
@@ -288,98 +273,6 @@ func (s *Store) legacyAuditDays() ([]string, error) {
})
return days, nil
}
func (s *Store) migrateCommands() error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
rows, err := tx.Query("SELECT id,proxy_id,target_id,source,protocol,kind,decision,content,at FROM commands ORDER BY rowid")
if err != nil {
return err
}
migrated := false
var shardTx *sql.Tx
var shardDay string
batchSize := 0
defer func() {
if shardTx != nil {
shardTx.Rollback()
}
}()
for rows.Next() {
var id, proxy, target, source, protocol, kind, decision, at string
var content []byte
if err = rows.Scan(&id, &proxy, &target, &source, &protocol, &kind, &decision, &content, &at); err != nil {
rows.Close()
return err
}
date, e := time.Parse(time.RFC3339Nano, at)
if e != nil {
rows.Close()
return e
}
day := date.UTC().Format("2006-01-02")
if shardTx != nil && (day != shardDay || batchSize >= 1000) {
if e = shardTx.Commit(); e != nil {
rows.Close()
return e
}
shardTx = nil
}
if shardTx == nil {
db, e := s.auditDB(day)
if e != nil {
rows.Close()
return e
}
shardTx, e = db.Begin()
if e != nil {
rows.Close()
return e
}
shardDay, batchSize = day, 0
}
if _, e = shardTx.Exec("INSERT OR IGNORE INTO commands(id,proxy_id,target_id,source,protocol,kind,decision,content,at) VALUES(?,?,?,?,?,?,?,?,?)", id, proxy, target, source, protocol, kind, decision, content, at); e != nil {
rows.Close()
return e
}
// A previous interrupted migration may already have copied this row.
// Only discard the legacy copy when all persisted values match exactly.
var matches int
e = shardTx.QueryRow("SELECT COUNT(*) FROM commands WHERE id=? AND proxy_id=? AND target_id=? AND source=? AND protocol=? AND kind=? AND decision=? AND content=? AND at=?", id, proxy, target, source, protocol, kind, decision, content, at).Scan(&matches)
if e != nil || matches != 1 {
rows.Close()
if e != nil {
return e
}
return errors.New("audit migration found conflicting record " + id)
}
migrated = true
batchSize++
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if migrated {
if err = shardTx.Commit(); err != nil {
return err
}
shardTx = nil
if _, err = tx.Exec("DELETE FROM commands"); err != nil {
return err
}
}
if err = tx.Commit(); err != nil {
return err
}
if migrated {
_, err = s.db.Exec("VACUUM")
}
return err
}
func (s *Store) pruneCommands(cutoff time.Time) {
s.auditOps.Lock()
defer s.auditOps.Unlock()
@@ -387,10 +280,6 @@ func (s *Store) pruneCommands(cutoff time.Time) {
return
}
cutoff = cutoff.UTC()
if s.portable && !s.shardedAudit {
s.prunePortableAudit(cutoff)
return
}
days, err := s.auditDays()
if err != nil {
return

Some files were not shown because too many files have changed in this diff Show More