升级 Go 工具链并完善 MySQL 策略、Kubernetes 端口和主题切换

This commit is contained in:
曾志威
2026-10-03 15:31:42 +08:00
parent ce2da77ddc
commit 1a05cd359f
56 changed files with 794 additions and 97 deletions
+2 -2
View File
@@ -17,9 +17,9 @@ steps:
- npm --prefix web run build - npm --prefix web run build
- name: build-binaries - name: build-binaries
image: golang:1.25.0-alpine image: golang:1.27.1-alpine
environment: environment:
GOTOOLCHAIN: go1.25.0 GOTOOLCHAIN: go1.27.1
CGO_ENABLED: "0" CGO_ENABLED: "0"
BUILD_JOBS: "1" BUILD_JOBS: "1"
GO_BUILD_PARALLELISM: "1" GO_BUILD_PARALLELISM: "1"
+1 -1
View File
@@ -13,7 +13,7 @@
- Document every behavior change with its reason, resulting behavior, affected scope, compatibility or migration impact, and actual validation. Keep the relevant feature document and `docs/change-rationale.md` current in the same commit; do not leave the rationale only in chat or commit messages. - Document every behavior change with its reason, resulting behavior, affected scope, compatibility or migration impact, and actual validation. Keep the relevant feature document and `docs/change-rationale.md` current in the same commit; do not leave the rationale only in chat or commit messages.
- Use Go 1.25.0 (`GOTOOLCHAIN=go1.25.0`); newer installed toolchains may not compile the pinned Vitess dependency. - Use Go 1.27.1 (`GOTOOLCHAIN=go1.27.1`), matching the current dependencies and build scripts.
- Backend: `go test ./... -count=1 -timeout=120s` and `go vet ./...`. - Backend: `go test ./... -count=1 -timeout=120s` and `go vet ./...`.
- Frontend: `npm.cmd --prefix web run build`. Run the Playwright suite in `web` against a freshly built executable using `LINKPORT_E2E_BINARY`. - Frontend: `npm.cmd --prefix web run build`. Run the Playwright suite in `web` against a freshly built executable using `LINKPORT_E2E_BINARY`.
- Do not build embedded Go assets while Vite is replacing `web/dist`. Restore tracked `web/dist/.gitkeep` after a frontend build if needed. - Do not build embedded Go assets while Vite is replacing `web/dist`. Restore tracked `web/dist/.gitkeep` after a frontend build if needed.
+4 -4
View File
@@ -53,13 +53,13 @@
## 构建 ## 构建
项目包含 `.drone.yml`,执行前端构建、Go 1.25.0 无 CGO 六平台编译、发布脚本测试及 Linux amd64 隔离启动验证。linux/amd64、linux/arm64、windows/amd64、windows/arm64、darwin/amd64、darwin/arm64 依次构建,平台并发和 Go 编译并发默认均为 1。六个独立压缩包保留在 `bin/releases/ci/`,附发布清单和 SHA256;打包成功后直接上传 IPFS,再发布下载索引目录并输出完整下载地址,不按构建事件跳过。API 与认证令牌直接配置在 YAML 中,无需 Drone Secret;令牌对仓库读者可见。不再构建或推送 Docker 镜像,也不使用 Gitea 包仓库,详见 [Drone/IPFS 发布说明](docs/ipfs.md)。流水线不读取真实 `data/`;真实服务矩阵仍使用 `scripts/test-matrix.ps1` 或 `scripts/test-matrix.sh`。 项目包含 `.drone.yml`,执行前端构建、Go 1.27.1 无 CGO 六平台编译、发布脚本测试及 Linux amd64 隔离启动验证。linux/amd64、linux/arm64、windows/amd64、windows/arm64、darwin/amd64、darwin/arm64 依次构建,平台并发和 Go 编译并发默认均为 1。六个独立压缩包保留在 `bin/releases/ci/`,附发布清单和 SHA256;打包成功后直接上传 IPFS,再发布下载索引目录并输出完整下载地址,不按构建事件跳过。API 与认证令牌直接配置在 YAML 中,无需 Drone Secret;令牌对仓库读者可见。不再构建或推送 Docker 镜像,也不使用 Gitea 包仓库,详见 [Drone/IPFS 发布说明](docs/ipfs.md)。流水线不读取真实 `data/`;真实服务矩阵仍使用 `scripts/test-matrix.ps1` 或 `scripts/test-matrix.sh`。
项目版本统一维护在根目录 `VERSION`。`linkport.exe version` 或 `linkport.exe --version --json` 可查看版本、Git 提交、构建时间与平台,管理界面顶部也可查看版本信息。构建/打包脚本自动注入元数据;直接 `go build` 仍使用 VERSION 的版本值,但提交和时间可能显示 `unknown`。 项目版本统一维护在根目录 `VERSION`。`linkport.exe version` 或 `linkport.exe --version --json` 可查看版本、Git 提交、构建时间与平台,管理界面顶部也可查看版本信息。构建/打包脚本自动注入元数据;直接 `go build` 仍使用 VERSION 的版本值,但提交和时间可能显示 `unknown`。
目标出口新增内置 `K8s port-forward`,不依赖 kubectl,支持 Pod、Service、Deployment 和 StatefulSet。配置与权限见 [Kubernetes 说明](docs/kubernetes.md)。 目标出口新增内置 `K8s port-forward`,不依赖 kubectl,支持 Pod、Service、Deployment 和 StatefulSet。配置与权限见 [Kubernetes 说明](docs/kubernetes.md)。
需要 Node.js 22.12+ 和 Go。当前 Vitess 解析器固定使用 **Go 1.25.0**;机器安装更新版 Go 时,设置 `GOTOOLCHAIN=go1.25.0` 自动使用兼容工具链。不要直接用 Go 1.27 编译该版本 Vitess。 需要 Node.js 22.12+ 和 **Go 1.27.1**。设置 `GOTOOLCHAIN=go1.27.1` 使用与当前依赖和构建脚本一致的工具链。
Windows PowerShell: Windows PowerShell:
@@ -144,7 +144,7 @@ MySQL/MongoDB 网关允许普通连接;配置证书后同一端口也支持客
本机开发可生成只覆盖 `localhost`、`127.0.0.1` 和 `::1`、有效期 14 天的证书: 本机开发可生成只覆盖 `localhost`、`127.0.0.1` 和 `::1`、有效期 14 天的证书:
```powershell ```powershell
$env:GOTOOLCHAIN = 'go1.25.0' $env:GOTOOLCHAIN = 'go1.27.1'
go run ./cmd/devcert go run ./cmd/devcert
$env:LINKPORT_TLS_CERT = 'data/tls/cert.pem' $env:LINKPORT_TLS_CERT = 'data/tls/cert.pem'
$env:LINKPORT_TLS_KEY = 'data/tls/key.pem' $env:LINKPORT_TLS_KEY = 'data/tls/key.pem'
@@ -174,7 +174,7 @@ $env:LINKPORT_TLS_KEY = 'data/tls/key.pem'
## 开发与测试 ## 开发与测试
```powershell ```powershell
$env:GOTOOLCHAIN = 'go1.25.0' $env:GOTOOLCHAIN = 'go1.27.1'
go test ./... go test ./...
go vet ./... go vet ./...
npm.cmd --prefix web run build npm.cmd --prefix web run build
+8
View File
@@ -2,8 +2,10 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath"
"strings" "strings"
"golang.org/x/term" "golang.org/x/term"
@@ -45,6 +47,12 @@ func run() error {
} }
defer prepared.Close() defer prepared.Close()
path := prepared.Data path := prepared.Data
logFile, logErr := os.OpenFile(filepath.Join(filepath.Dir(path), "linkport.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if logErr != nil {
return fmt.Errorf("open log file: %w", logErr)
}
defer logFile.Close()
log.SetOutput(io.MultiWriter(os.Stderr, logFile))
var local *desktop.Config var local *desktop.Config
if desktop.Enabled() { if desktop.Enabled() {
local = prepared local = prepared
+1 -1
View File
@@ -52,6 +52,6 @@ $env:LINKPORT_NODE = 'tools/node/node.exe'
.\start-dev.cmd -BuildOnly .\start-dev.cmd -BuildOnly
``` ```
`-BuildOnly` 只构建候选程序,不启动、不停止或替换现有服务,也不迁移真实数据库。构建脚本固定 `GOTOOLCHAIN=go1.25.0`;本次不自动下载、安装或打包编译器,已有 Go 的工具链获取仍由 Go 自身及网络配置决定。工具缺失时在服务停机和处理数据之前失败,并提示放入项目工具目录或配置 PATH。安装工具或修改用户 PATH 后需重新打开终端。 `-BuildOnly` 只构建候选程序,不启动、不停止或替换现有服务,也不迁移真实数据库。构建脚本固定 `GOTOOLCHAIN=go1.27.1`;本次不自动下载、安装或打包编译器,已有 Go 的工具链获取仍由 Go 自身及网络配置决定。工具缺失时在服务停机和处理数据之前失败,并提示放入项目工具目录或配置 PATH。安装工具或修改用户 PATH 后需重新打开终端。
验证记录见 [测试记录](testing.md)。 验证记录见 [测试记录](testing.md)。
+32
View File
@@ -1,5 +1,11 @@
# 变更原因与设计记录 # 变更原因与设计记录
## Kubernetes 本机高位和固定端口(2026-10-02)
原因:用户要求使用高位端口,并能在页面配置固定端口。结果:Kubeconfig 代理提供自动/固定两种模式,自动分配 49152–65535,绑定失败最多尝试 32 个端口;固定范围为 1024–65535,占用或绑定拒绝时明确报错,不自动改端口。仅监听 127.0.0.1。字段为 outbound.localPort,省略或 0 为自动,旧配置继续兼容,无迁移。目标数据库端口和网关对外端口不变。独立隧道无法共享同一固定端口,具体范围、持久连接及 SPDY 超时限制见 [Kubernetes 配置](kubernetes.md)。
实际验证:Go 1.27.1 全量测试中的 model、outbound、gateway 和所有协议包通过,新增 SPDY fixture 覆盖高位范围、固定端口通信、冲突拒绝及关闭后重新绑定。没有真实 Kubernetes 集群验证。go vet ./...、前端类型检查和生产构建通过,新嵌入程序 artifacts/ansi-insert/linkport.exe 构建通过,恢复 web/dist/.gitkeep。Playwright 使用新程序及隔离数据库/28181、28182 端口,代理上传与保存回读用例通过,覆盖固定端口 55032、编辑保留和 390px 无溢出;同文件资产重传用例在搜索框定位处超时,未进入端口配置。全量后端仍复现 API WebDAV 口令、cluster RSA fixture panic、desktop Windows 数据路径和 syncjob 明文秘密断言失败,不记为全套通过。前端保留 Node 22.10/Vite 推荐版本和 chunk 大小提示。未部署真实服务或修改真实目标数据。
## 默认 SQLite 数据目录改为用户目录(2026-10-01) ## 默认 SQLite 数据目录改为用户目录(2026-10-01)
原因:工作区下的 `data/linkport.sqlite` 不适合作为桌面应用默认数据位置。 原因:工作区下的 `data/linkport.sqlite` 不适合作为桌面应用默认数据位置。
@@ -1149,3 +1155,29 @@ live MySQL server or real data was used.
范围与兼容:仅调整前端展示顺序,不改变选中值、接口请求、权限或存储格式,无需迁移。 范围与兼容:仅调整前端展示顺序,不改变选中值、接口请求、权限或存储格式,无需迁移。
验证:`npm.cmd --prefix web run build` 通过,`git diff --check` 通过;未连接真实目标或修改真实数据。 验证:`npm.cmd --prefix web run build` 通过,`git diff --check` 通过;未连接真实目标或修改真实数据。
## 双引号 INSERT 原样安全放行及 Go 工具链升级(2026-10-02)
原因:用户的导入 SQL 使用双引号引用库、表、列,不能修改原文;现有 Vitess 默认解析与固定上游 SQL 模式均不支持这些标识符,返回 `unsafe_sql`。用户同时要求升级旧的 Go 1.25 指定版本。
结果:安全读写模式增加 TiDB `ANSI_QUOTES` 完整解析的单条 INSERT VALUES 兼容路径,仅接纳字面量和占位符数据,转发前临时启用相同 SQL 模式,处理响应后恢复。原始 SQL/JSON/审计文本保持不变,切换不覆盖字符集和结果编码;上游 SQL 错误仍转发,模式设置失败关闭连接。只读、无 WHERE 更新删除、写入批次、动态 SQL及特殊注释控制保留。范围包括普通 SQL 与二进制预处理准备;详细兼容边界见 [MySQL 安全写入](mysql-write-safety.md)。
范围与兼容:仅新增 MySQL INSERT 解析路径及上游临时模式切换,补齐现有 TiDB 解析器需要的 failpoint 间接依赖。Go 最低版本和当前协作/构建/测试说明统一为 1.27.1,构建脚本指定同一版本,保留历史验证记录中的实际版本。原 INSERT 的协议响应保留,但临时模式 SET 会影响后续单独查询的会话诊断信息,具体限制见功能文档。无数据库迁移,需使用新程序并重新连接;未部署或修改真实 data,也未执行真实业务 SQL。
实际验证:Go 1.27.1 下 MySQL 包全部测试通过,覆盖超过 1 MiB、12,001 行、JSON/转义字符串、原文转发与审计、普通/预处理、上游 SQL 错误后的模式恢复、启用/恢复失败关闭连接、只读拒绝、控制字节与附加危险请求不转发。测试使用本机协议 fixture,没有连接真实 MySQL。前端生产构建及 `artifacts/ansi-insert/linkport.exe` 新嵌入程序构建通过;Go 发现脚本 12 项断言通过。前端构建提示 Node 22.10.0 低于推荐的 22.12+,仍构建成功,并保留原有 chunk 体积提示;已恢复 web/dist/.gitkeep。
后端全量 `go test ./... -count=1 -timeout=120s` 未通过,串行 `go test -p=1 ./... -count=1 -timeout=120s` 复现相同业务失败:API 的 TestWebDAVSyncSettingsAndAuthorization(口令未保留);cluster 的 TestMySQLReplicationAndGroupDiscovery(fixture RSA 认证 nil panic);desktop 的路径解析和无效目录相关用例;syncjob 的 TestTwoDeviceSyncConflictsAndDeletion 与 TestWebDAVIndependentEncryptedBackupsDoNotChangeSyncBaseline(明文秘密断言)。MySQL 及其他协议包全部通过。并发 vet/测试首次遇到 Windows 子进程 Access denied/exit status 9;`go vet -p=1 ./...` 通过。不将全量后端记为通过。使用上述新程序及隔离数据库/28181、28182 端口执行完整 Playwright,在用户追加模式要求时停止,已运行 73/139 项并包含页面失败,不记为全套通过。
## MySQL 只读、读写、全放行模式选择(2026-10-02)
原因:用户要求把三种策略明确拆成可选择的模式;原界面网关级仅有只读开关,第三种模式只在账号权限内提供,名称也不够明确。
结果与范围:MySQL 网关默认策略提供只读、读写、全放行三选一;账号权限提供相同选项及“继承网关”。网关与账号列表统一使用新名称。新建 MySQL 账号默认继承,已有账号显式策略不改变;网关只读仍优先于全部账号。策略使用现有 readonly/readwrite/unrestricted 值,无存储迁移。读写保留 WHERE 与解析保护以及双引号 INSERT 兼容,全放行跳过 SQL 策略但保留认证、来源限制、审计和协议检查。MongoDB 继续使用只读开关且不提供全放行。模式选项放在独立 GatewayAccessMode 组件,App.vue 只协调草稿。详见 [MySQL 账号模式](mysql-access-modes.md)。
验证:Go 1.27.1 model/mysqlproxy/API 模式与写入安全专项通过,新增 API 回读覆盖三种网关默认策略及账号继承;前端模式和草稿 11 项 Node 测试通过。前端生产构建与新嵌入程序构建通过,`go vet -p=1 ./...` 通过,恢复 web/dist/.gitkeep。新程序运行 MySQL 模式 Playwright 专项通过,覆盖账号三种选择、继承显示、保存重开、320px 无横向溢出及网关只读优先;浏览器首次发现继承空字符串被 Element Plus 当作占位符,改为界面专用 inherit 值、保存映射回空值后通过。此前完整 Playwright 在用户追加模式要求时停止,已运行 73/139 项且包含页面断言/超时失败,未记为全套通过。协议和浏览器均使用 fixture 或隔离测试配置,不连接真实目标、不部署真实服务。
## 主题快捷切换(2026-10-02)
原因与结果:用户要求增加主题切换。现有四种主题下拉保留,登录页和工作区工具栏增加太阳/月亮一键切换按钮;跟随系统时按当前实际主题决定切换方向。继续复用原偏好存储键,刷新保留选择;仅改变主题状态和控件,无后端接口、数据或配置迁移。范围为 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 通过。全套回归和后端失败沿用上方实际记录,不因专项通过而宣称全套通过。
+8 -1
View File
@@ -17,6 +17,7 @@ linkport 内置 Kubernetes 客户端,不需要安装 `kubectl`。在「代理
| Context | kubeconfig 中的 context 名称;留空使用当前 context | | Context | kubeconfig 中的 context 名称;留空使用当前 context |
| 命名空间 | 选择资产或切换 Context 后自动查询,从下拉框选择;初始使用 context 的命名空间或 `default`,查询失败可重试 | | 命名空间 | 选择资产或切换 Context 后自动查询,从下拉框选择;初始使用 context 的命名空间或 `default`,查询失败可重试 |
| Kubernetes 资源 | `pod/mysql-0`、`service/mysql`、`deployment/mysql` 或 `statefulset/mongo` | | Kubernetes 资源 | `pod/mysql-0`、`service/mysql`、`deployment/mysql` 或 `statefulset/mongo` |
| 本机监听端口 | 自动模式在 `49152–65535` 分配;固定模式填写 `1024–65535`,仅监听 linkport 所在机器的 `127.0.0.1`,不是数据库目标端口或网关对外端口 |
| 目标端口 | Pod/Deployment/StatefulSet 使用容器 TCP 端口;Service 使用 Service 的 port,自动解析数字或命名 targetPort | | 目标端口 | Pod/Deployment/StatefulSet 使用容器 TCP 端口;Service 使用 Service 的 port,自动解析数字或命名 targetPort |
| 目标主机 | 保留数据库 TLS 的证书主机名;实际连接通过 Kubernetes API 转发,不在本机解析该主机 | | 目标主机 | 保留数据库 TLS 的证书主机名;实际连接通过 Kubernetes API 转发,不在本机解析该主机 |
@@ -34,10 +35,16 @@ MongoDB 同样引用已保存的 K8s 代理:例如代理选择 `pod/mongo-0`
例如 MongoDB StatefulSet:从下拉框选择实际部署的命名空间,查询后选择 `statefulset/mongo`,目标端口填 `27017`。需要 `apps` API 组下 `statefulsets` 的 `get` 和 `list` 权限。按 StatefulSet 选择 Pod 不会识别 MongoDB 主节点;如需固定成员,可在查询结果中选择 `pod/mongo-0`。切换资产、Context 或命名空间后需重新查询和选择;编辑已有配置时可以看到已保存的资源,未查询验证时会明确标注。 例如 MongoDB StatefulSet:从下拉框选择实际部署的命名空间,查询后选择 `statefulset/mongo`,目标端口填 `27017`。需要 `apps` API 组下 `statefulsets` 的 `get` 和 `list` 权限。按 StatefulSet 选择 Pod 不会识别 MongoDB 主节点;如需固定成员,可在查询结果中选择 `pod/mongo-0`。切换资产、Context 或命名空间后需重新查询和选择;编辑已有配置时可以看到已保存的资源,未查询验证时会明确标注。
每条上游连接使用独立的 port-forward 通道和随机本地回环端口。连接关闭、网关停止或建连失败时清理通道和监听端口,不回退直连。建立成功的数据库会话不受建连超时影响。Pod 重启或网络中断后,原会话断开;客户端重连时重新选择 Pod,不自动重放 SQL 或消息。 默认本机监听端口为自动高位端口,绑定冲突时最多尝试 32 个端口;只有绑定失败重试,不重试认证或 API 错误。页面「代理管理 → Kubeconfig → 本机监听端口」可切换固定端口,固定端口占用或绑定被拒绝时明确报错,不自动换端口。API 字段为 `outbound.localPort`,省略或 `0` 为自动模式,无存储迁移。
普通上游连接拥有独立 port-forward 通道;开启持久连接后,同一运行时隧道可复用本机监听端口。固定端口不能同时用于多个独立隧道,多个目标、网关或并发非持久连接共享相同配置时可能冲突,应使用自动模式或不同端口。连接关闭、网关停止或建连失败时清理通道和监听端口,不回退直连。建立成功的数据库会话不受建连超时影响。Pod 重启或网络中断后,原会话断开;客户端重连时重新选择 Pod,不自动重放 SQL 或消息。
`error creating error stream ... Timeout occurred` 属于 Kubernetes SPDY 流建立超时;使用高位或固定端口不保证修复此问题,还需检查 API 流式升级、集群网络和长连接状态。
当前使用 Kubernetes SPDY port-forward 协议,仅支持 TCP,要求 linkport 直接访问集群 API;暂不支持 kubeconfig 的 `proxy-url` 或匹配 API 地址的 HTTP(S) 代理环境变量,配置这些代理时明确返回失败。集群 API 自身的反向代理需要允许流式连接升级。普通 kubeconfig 证书、Token 认证无需 kubectl;特定云平台认证和真实集群兼容性需在实际环境验证。 当前使用 Kubernetes SPDY port-forward 协议,仅支持 TCP,要求 linkport 直接访问集群 API;暂不支持 kubeconfig 的 `proxy-url` 或匹配 API 地址的 HTTP(S) 代理环境变量,配置这些代理时明确返回失败。集群 API 自身的反向代理需要允许流式连接升级。普通 kubeconfig 证书、Token 认证无需 kubectl;特定云平台认证和真实集群兼容性需在实际环境验证。
## 本轮验证 ## 本轮验证
2026-10-02:Go 1.27.1 model/outbound 专项和 vet 通过,SPDY fixture 验证自动高位范围、固定端口通信、占用拒绝与关闭释放;前端类型检查、生产构建和新嵌入程序构建通过。隔离 Playwright 端口专项通过,保存/编辑回读 55032 并检查 390px 布局与截图;同文件资产重传用例在搜索框处超时。全量后端仍存在此前 API、cluster、desktop、syncjob 失败,详见 [变更记录](change-rationale.md)。未连接真实 Kubernetes 集群,未部署到真实服务。
2026-09-21:前端类型检查、生产构建与 7 项隔离浏览器回归通过,覆盖跨范围 Kubeconfig 选择、普通文件排除、代理保存、Mongo 目标引用与回读、刷新失败重试、390px 小屏,以及原有资产更新、项目筛选和就地上传。Go 1.25.0 全量测试和 vet 串行复跑通过;全量浏览器检查为 79 通过、3 项已记录失败、8 未运行,详情见 [测试记录](testing.md)。Kubernetes 命名空间/资源查询使用模拟响应,资产及配置保存使用真实隔离 API;没有连接真实 MongoDB 或 Kubernetes 集群,不作为真实 port-forward 连通性验收。 2026-09-21:前端类型检查、生产构建与 7 项隔离浏览器回归通过,覆盖跨范围 Kubeconfig 选择、普通文件排除、代理保存、Mongo 目标引用与回读、刷新失败重试、390px 小屏,以及原有资产更新、项目筛选和就地上传。Go 1.25.0 全量测试和 vet 串行复跑通过;全量浏览器检查为 79 通过、3 项已记录失败、8 未运行,详情见 [测试记录](testing.md)。Kubernetes 命名空间/资源查询使用模拟响应,资产及配置保存使用真实隔离 API;没有连接真实 MongoDB 或 Kubernetes 集群,不作为真实 port-forward 连通性验收。
+5 -3
View File
@@ -2,16 +2,18 @@
## 模式选择 ## 模式选择
在网关编辑器的「账号 → 权限 → 访问模式」中选择。为解决复制客户端被协议白名单拒绝,以及不同账号需要不同 SQL 检查强度的问题,MySQL 提供以下三种模式: 在网关编辑器的「网关与访问 → 网关访问模式」中选择默认策略;在「账号 → 权限 → 访问模式」中选择继承网关或单独覆盖。MySQL 提供以下三种模式:
| 模式 | 保存值 | SQL 行为 | | 模式 | 保存值 | SQL 行为 |
| --- | --- | --- | | --- | --- | --- |
| 只读 | `readonly` | 保留只读 AST 白名单,拒绝写入,包括带 WHERE 的 UPDATE/DELETE。 | | 只读 | `readonly` | 保留只读 AST 白名单,拒绝写入,包括带 WHERE 的 UPDATE/DELETE。 |
| 安全读写(默认) | `readwrite` | UPDATE/DELETE 必须有自身的外层 WHERE;其他检查和边界见[写入保护](mysql-write-safety.md)。 | | 读写(默认) | `readwrite` | UPDATE/DELETE 必须有自身的外层 WHERE;双引号 INSERT VALUES 兼容及其他边界见[写入保护](mysql-write-safety.md)。 |
| 普通读写 | `unrestricted` | 不执行网关 SQL AST/WHERE 检查,原样转发 SQL、预处理和批次,由目标账号权限及数据库规则决定是否执行。 | | 全放行 | `unrestricted` | 不执行网关 SQL AST/WHERE 检查,原样转发 SQL、预处理和批次,由目标账号权限及数据库规则决定是否执行。 |
旧配置中的空值或 `readwrite` 保持安全读写行为,不会自动变成普通读写。网关级只读限制优先,不能通过账号选择普通读写绕过;普通读写模式只适用于 MySQL,MongoDB 等其他协议不增加此选项。模式仍使用现有 JSON 配置字段,无数据库迁移;已有连接沿用建立时的策略,修改后重新建立连接使用新模式。 旧配置中的空值或 `readwrite` 保持安全读写行为,不会自动变成普通读写。网关级只读限制优先,不能通过账号选择普通读写绕过;普通读写模式只适用于 MySQL,MongoDB 等其他协议不增加此选项。模式仍使用现有 JSON 配置字段,无数据库迁移;已有连接沿用建立时的策略,修改后重新建立连接使用新模式。
2026-10-02:界面将原“安全读写/普通读写”名称统一为“读写/全放行”,网关默认策略由只读开关改为三选一下拉框。保存值和既有账号策略保持兼容;新建 MySQL 账号默认继承网关,已有账号继续使用保存的显式模式。网关设为全放行时,显式读写账号仍执行写入保护;设为只读时所有账号只读。全放行保留鉴权、来源限制、审计和协议报文检查,不等同跳过数据库权限,也不会自动启用 ANSI_QUOTES。
普通读写模式不固定目标会话 SQL mode,也不在 RESET CONNECTION 后重新设置解析兼容模式,支持数据库自身认可的语法、SQL 动态预处理及会话设置。鉴权、账号目标映射、来源限制、命令审计失败关闭连接和报文格式检查继续适用。它不是绕过网关认证的 TCP 透传模式。 普通读写模式不固定目标会话 SQL mode,也不在 RESET CONNECTION 后重新设置解析兼容模式,支持数据库自身认可的语法、SQL 动态预处理及会话设置。鉴权、账号目标映射、来源限制、命令审计失败关闭连接和报文格式检查继续适用。它不是绕过网关认证的 TCP 透传模式。
## 常用协议命令 ## 常用协议命令
+11 -1
View File
@@ -17,7 +17,7 @@ DELETE FROM users WHERE id = ?;
检查使用 Vitess SQL AST,覆盖大小写、CTE、别名和多表语法。普通查询与二进制预处理的准备阶段都在转发上游前检查,执行阶段只接受已登记的预处理语句 ID。多语句请求整体预检查,不会先执行前半段再拒绝后半段;现有网关仍只支持符合只读白名单的批次,不因此放开写入批次。 检查使用 Vitess SQL AST,覆盖大小写、CTE、别名和多表语法。普通查询与二进制预处理的准备阶段都在转发上游前检查,执行阶段只接受已登记的预处理语句 ID。多语句请求整体预检查,不会先执行前半段再拒绝后半段;现有网关仍只支持符合只读白名单的批次,不因此放开写入批次。
为限制解析开销,单次 SQL 文本最多 1 MiB、批次最多 100 条语句、单条 AST 最多遍历 10,000 个节点;超过上限拒绝。大批量导入客户端需要相应缩小请求。 安全读写检查保留批次最多 100 条语句的限制;SQL 文本和 AST 节点数量限制已在 2026-09-30 解除,见下方大批量 SQL 说明。只读白名单仍有独立的解析资源限制。
缺少外层条件时返回 MySQL 原生错误码 1290(`ER_OPTION_PREVENTS_STATEMENT`),消息说明 UPDATE/DELETE 缺少外层 WHERE。命令审计记为 `blocked`,连接事件记录 `mysql:missing_where`;其他无法安全检查的 SQL 记录 `mysql:unsafe_sql`。拒绝请求不会转发上游,正常情况下连接继续可用;既有审计失败关闭连接的行为保留。只读账号仍执行更严格的只读白名单,继续拒绝带 WHERE 的写操作。 缺少外层条件时返回 MySQL 原生错误码 1290(`ER_OPTION_PREVENTS_STATEMENT`),消息说明 UPDATE/DELETE 缺少外层 WHERE。命令审计记为 `blocked`,连接事件记录 `mysql:missing_where`;其他无法安全检查的 SQL 记录 `mysql:unsafe_sql`。拒绝请求不会转发上游,正常情况下连接继续可用;既有审计失败关闭连接的行为保留。只读账号仍执行更严格的只读白名单,继续拒绝带 WHERE 的写操作。
@@ -37,3 +37,13 @@ DELETE FROM users WHERE id = ?;
## 2026-09-30:大批量 SQL ## 2026-09-30:大批量 SQL
安全写入检查不再以 SQL 超过 1 MiB 或 AST 超过 10,000 个节点为由拒绝请求,普通 SQL 与二进制预处理共用此行为。保留 SQL 解析、UPDATE/DELETE 外层 WHERE、特殊注释及会话设置校验,以及 100 条多语句上限。只读模式仍拒绝 INSERT;协议包和上游数据库限制仍适用。无配置或数据迁移,需部署新程序后生效。回归覆盖超过 1 MiB 的 INSERT、12,001 行字面量/占位符 INSERT,以及大批量语句后无 WHERE 的 DELETE 拒绝。 安全写入检查不再以 SQL 超过 1 MiB 或 AST 超过 10,000 个节点为由拒绝请求,普通 SQL 与二进制预处理共用此行为。保留 SQL 解析、UPDATE/DELETE 外层 WHERE、特殊注释及会话设置校验,以及 100 条多语句上限。只读模式仍拒绝 INSERT;协议包和上游数据库限制仍适用。无配置或数据迁移,需部署新程序后生效。回归覆盖超过 1 MiB 的 INSERT、12,001 行字面量/占位符 INSERT,以及大批量语句后无 WHERE 的 DELETE 拒绝。
## 2026-10-02:原样放行双引号 INSERT
安全读写模式兼容使用双引号引用库、表、列的单条 `INSERT ... VALUES`,例如 `INSERT INTO "shopee-wes-test"."ttx_action_right" ("tid", "structure") VALUES ('ttx.wso.Bill', '[{"checked":true}]')`。原 Vitess 检查失败后,使用已固定版本的 TiDB MySQL 解析器按 `ANSI_QUOTES` 模式完整解析原文,仅放行 VALUES 中的字面量、NULL、布尔值、占位符及带正负号的字面量。拒绝 INSERT SELECT、动态表达式、ON DUPLICATE KEY UPDATE、REPLACE、附加语句、可执行注释和优化器提示;特殊注释标记在此兼容路径中保守拒绝,包括字符串内的同样标记。
转发前临时将上游 `sql_mode` 设为 `STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,ANSI_QUOTES`,原始 SQL、JSON 字符串和审计文本均不改写。普通查询和二进制预处理准备阶段共用此路径;响应处理后恢复原安全 SQL 模式,包括上游返回 SQL 错误的情况。切换不改变客户端字符集、结果编码或排序规则。启用或恢复模式失败时关闭连接,启用失败不发送 INSERT。后续普通双引号字符串保持原来的解释方式。
只读账号继续拒绝全部 INSERT;UPDATE/DELETE 外层 WHERE、动态 SQL 和批次限制保留。普通读写模式继续由目标数据库决定语法及权限,不自动切换模式。无配置、存储或数据迁移;升级程序并重新连接后生效,不需要改导入 SQL。实现需要目标支持 `ANSI_QUOTES`。协议回归为本机 MySQL fixture,并非真实服务器执行验证;实际全量检查结果见 [变更记录](change-rationale.md)。
临时模式设置额外增加两次上游请求。原 INSERT 响应的影响行数、错误及警告计数保留,但随后单独查询的 `ROW_COUNT()`、`SHOW WARNINGS` 等会话诊断信息可能反映恢复模式的 SET,不能当作原 INSERT 的诊断结果。
+3 -2
View File
@@ -395,14 +395,15 @@ Docker 真实服务矩阵尚未在本机执行,不能据此宣称全部目标
## 本地测试 ## 本地测试
统一使用 Go 1.25.0。Windows 已安装更高版本 Go 时设置 `GOTOOLCHAIN=go1.25.0`;首次使用需下载兼容工具链。 统一使用 Go 1.27.1,与当前依赖和构建脚本一致。设置 `GOTOOLCHAIN=go1.27.1`;首次使用可能需要下载工具链。
```powershell ```powershell
$env:GOTOOLCHAIN = 'go1.25.0' $env:GOTOOLCHAIN = 'go1.27.1'
go test ./... go test ./...
go vet ./... go vet ./...
npm.cmd --prefix web run build npm.cmd --prefix web run build
go build -o bin/linkport.exe ./cmd/linkport go build -o bin/linkport.exe ./cmd/linkport
$env:LINKPORT_E2E_BINARY = Join-Path (Get-Location) 'bin/linkport.exe'
npm.cmd --prefix web test npm.cmd --prefix web test
``` ```
+2
View File
@@ -53,6 +53,8 @@ linkport 的全部工作区页面采用参考本地 `C:\work\github\dbx` 的紧
`ThemePicker.vue` 复用原来的 `theme.ts` 状态,不创建第二套偏好存储。`SidebarNavigation.vue` 继续使用 App 提供的菜单、计数、搜索和导航事件。 `ThemePicker.vue` 复用原来的 `theme.ts` 状态,不创建第二套偏好存储。`SidebarNavigation.vue` 继续使用 App 提供的菜单、计数、搜索和导航事件。
2026-10-02:登录页和顶部工具栏的主题选择器增加太阳/月亮图标按钮,可直接在浅色与深色之间切换,悬停及可访问名称指示目标主题。浅色、深色、海蓝、跟随系统下拉选择保留,继续使用 linkport-theme 存储键。跟随系统时按钮读取实际深浅色状态,点击后保存明确的浅色或深色选择;无 API 或数据迁移。桌面和手机共用此控件,实际验证见变更记录。
## 验证与边界 ## 验证与边界
- 新增 `web/tests/dbx-ui.spec.ts`,覆盖 13 个菜单在浅色、深色、海蓝和桌面/手机下的主题颜色、圆角、导航状态、页面宽度及工具栏布局;另覆盖四类编辑器、页签 Home/End 切换、登录主题持久化和系统主题跟随。用例包含截图输出,但本次没有执行成功或生成截图。 - 新增 `web/tests/dbx-ui.spec.ts`,覆盖 13 个菜单在浅色、深色、海蓝和桌面/手机下的主题颜色、圆角、导航状态、页面宽度及工具栏布局;另覆盖四类编辑器、页签 Home/End 切换、登录主题持久化和系统主题跟随。用例包含截图输出,但本次没有执行成功或生成截图。
+1 -1
View File
@@ -54,4 +54,4 @@
`npm.cmd --prefix web run build` 在 Vite 启动 esbuild 时遭遇 `spawn EPERM`;Playwright 的 worker 同样无法启动。测试发现与单进程逻辑断言不等于浏览器用例通过。本轮未生成新前端产物/新嵌入二进制,未运行浏览器端到端回归或生成新截图;不能沿用旧二进制或历史截图作为本轮验收。 `npm.cmd --prefix web run build` 在 Vite 启动 esbuild 时遭遇 `spawn EPERM`;Playwright 的 worker 同样无法启动。测试发现与单进程逻辑断言不等于浏览器用例通过。本轮未生成新前端产物/新嵌入二进制,未运行浏览器端到端回归或生成新截图;不能沿用旧二进制或历史截图作为本轮验收。
解除环境限制后,先构建前端,再用 `GOTOOLCHAIN=go1.25.0` 编译新程序;将 `LINKPORT_E2E_BINARY` 指向新程序,使用隔离目录、空闲端口运行 `npm.cmd --prefix web test`。纯逻辑用例也可在 `web` 中运行 `npx.cmd playwright test --config playwright.domain.config.ts`。不得并行替换 `web/dist` 与编译 Go 嵌入资源。 解除环境限制后,先构建前端,再用 `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 嵌入资源。
+2 -1
View File
@@ -1,6 +1,6 @@
module linkport module linkport
go 1.27.0 go 1.27.1
require ( require (
github.com/go-mysql-org/go-mysql v1.16.0 github.com/go-mysql-org/go-mysql v1.16.0
@@ -55,6 +55,7 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 // indirect github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 // indirect
github.com/pingcap/failpoint v0.0.0-20260406204437-bbc9d102c19e // indirect
github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 // indirect github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20250313105119-ba97887b0a25 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+2
View File
@@ -108,6 +108,8 @@ github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 h1:cLgCk5mwDG9lDH+dPK8TmEliTjyGJwwKN0qevWAl8IY= github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2 h1:cLgCk5mwDG9lDH+dPK8TmEliTjyGJwwKN0qevWAl8IY=
github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2/go.mod h1:ktAJCA9lxrHHjVyVl2pKJFvzBnq2eZbb+CUOjBRPlXo= github.com/pingcap/errors v0.11.5-0.20260310054046-9c8b3586e4b2/go.mod h1:ktAJCA9lxrHHjVyVl2pKJFvzBnq2eZbb+CUOjBRPlXo=
github.com/pingcap/failpoint v0.0.0-20260406204437-bbc9d102c19e h1:il8go9El5o10EyPmalSG6Lg3zu2rtkq7c2wbRwBmdwo=
github.com/pingcap/failpoint v0.0.0-20260406204437-bbc9d102c19e/go.mod h1:jimwlLpI/XtwQdlZML15HS+j4rirvwZM0GLY07wwgOo=
github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 h1:A2bEfgSb7hLwR9mxDszgGKweF+xY9YoTDG+8RjdFjDE= github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1 h1:A2bEfgSb7hLwR9mxDszgGKweF+xY9YoTDG+8RjdFjDE=
github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1/go.mod h1:pxfz2oJfAuhwrb3/rcLqD//GS/5gRP4gD022iP3cEO0= github.com/pingcap/log v1.1.1-0.20260227082333-572e590d08f1/go.mod h1:pxfz2oJfAuhwrb3/rcLqD//GS/5gRP4gD022iP3cEO0=
github.com/pingcap/tidb/pkg/parser v0.0.0-20261001035437-93a01d31f6da h1:spEPpQbqFK1uvQTn7SHNi1PO4vA/7rqp3BlbJ3/s6Fo= github.com/pingcap/tidb/pkg/parser v0.0.0-20261001035437-93a01d31f6da h1:spEPpQbqFK1uvQTn7SHNi1PO4vA/7rqp3BlbJ3/s6Fo=
+16
View File
@@ -30,4 +30,20 @@ func TestMySQLAccountModesPersistThroughAPI(t *testing.T) {
t.Fatal("mode edit lost or exposed the account password") t.Fatal("mode edit lost or exposed the account password")
} }
} }
for _, mode := range []string{"readonly", "readwrite", "unrestricted"} {
w = f.request("PUT", path, map[string]any{"accessMode": mode, "gatewayAccounts": []any{
map[string]any{"id": "writer", "username": "writer", "accessMode": ""},
map[string]any{"id": "reader", "username": "reader", "accessMode": "readonly"},
}}, true)
requireStatus(t, w, 200)
w = f.request("GET", path, nil, false)
requireStatus(t, w, 200)
view := readView(t, w)
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)
}
}
} }
+4 -1
View File
@@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"linkport/internal/model" "linkport/internal/model"
"linkport/internal/outbound" "linkport/internal/outbound"
"log"
"runtime/debug"
"sync" "sync"
"time" "time"
) )
@@ -34,7 +36,8 @@ func (s *connectionSession) serve(handler model.Handler) {
defer s.connection.conn.Close() defer s.connection.conn.Close()
defer s.release() defer s.release()
defer func() { defer func() {
if recover() != nil { if value := recover(); value != nil {
log.Printf("protocol panic: %v\n%s", value, debug.Stack())
s.event("disconnected", "protocol_panic") s.event("disconnected", "protocol_panic")
} }
}() }()
+6
View File
@@ -19,6 +19,12 @@ var (
) )
func (o Outbound) ValidateKubernetes() error { func (o Outbound) ValidateKubernetes() error {
if o.LocalPort < 0 || o.LocalPort > 65535 {
return errors.New("Kubernetes local port must be 0 or 1024-65535")
}
if o.LocalPort > 0 && o.LocalPort < 1024 {
return errors.New("Kubernetes local port must be 0 or 1024-65535")
}
if o.KubeConfigAssetID != "" && !accountIDPattern.MatchString(o.KubeConfigAssetID) { if o.KubeConfigAssetID != "" && !accountIDPattern.MatchString(o.KubeConfigAssetID) {
return errors.New("invalid Kubeconfig asset id") return errors.New("invalid Kubeconfig asset id")
} }
+10
View File
@@ -7,6 +7,16 @@ import (
) )
func TestValidateKubernetes(t *testing.T) { func TestValidateKubernetes(t *testing.T) {
for _, port := range []int{0, 1024, 49152, 65535} {
if err := (Outbound{Resource: "pod/db", LocalPort: port}).ValidateKubernetes(); err != nil {
t.Fatal(err)
}
}
for _, port := range []int{-1, 1, 65536} {
if err := (Outbound{Resource: "pod/db", LocalPort: port}).ValidateKubernetes(); err == nil {
t.Errorf("accepted local port %d", port)
}
}
for _, resource := range []string{"pod/mysql-0", "service/mysql", "deployment/postgres", "statefulset/mongo"} { for _, resource := range []string{"pod/mysql-0", "service/mysql", "deployment/postgres", "statefulset/mongo"} {
if err := (Outbound{Resource: resource, Namespace: "my-app", KubeConfig: `C:\Users\test\.kube\config`}).ValidateKubernetes(); err != nil { if err := (Outbound{Resource: resource, Namespace: "my-app", KubeConfig: `C:\Users\test\.kube\config`}).ValidateKubernetes(); err != nil {
t.Fatal(err) t.Fatal(err)
+1
View File
@@ -54,6 +54,7 @@ type Outbound struct {
KubeContext string `json:"kubeContext,omitempty"` KubeContext string `json:"kubeContext,omitempty"`
Namespace string `json:"namespace,omitempty"` Namespace string `json:"namespace,omitempty"`
Resource string `json:"resource,omitempty"` Resource string `json:"resource,omitempty"`
LocalPort int `json:"localPort,omitempty"`
SingBoxOutbound string `json:"singBoxOutbound,omitempty"` SingBoxOutbound string `json:"singBoxOutbound,omitempty"`
SingBoxType string `json:"singBoxType,omitempty"` SingBoxType string `json:"singBoxType,omitempty"`
HostFingerprint string `json:"hostFingerprint,omitempty"` HostFingerprint string `json:"hostFingerprint,omitempty"`
+34 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"linkport/internal/model" "linkport/internal/model"
"math/rand/v2"
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
@@ -84,7 +85,7 @@ func startKubernetes(ctx context.Context, network, address string, o model.Outbo
} }
} }
done(nil) done(nil)
return startKubernetesConfig(ctx, config, namespace, o.Resource, port) return startKubernetesConfigWithLocalPort(ctx, config, namespace, o.Resource, port, o.LocalPort)
} }
func dialKubernetes(ctx context.Context, network, address string, o model.Outbound) (net.Conn, error) { func dialKubernetes(ctx context.Context, network, address string, o model.Outbound) (net.Conn, error) {
@@ -127,6 +128,32 @@ func dialKubernetesConfig(ctx context.Context, config *rest.Config, namespace, r
} }
func startKubernetesConfig(ctx context.Context, config *rest.Config, namespace, resource string, port int) (_ *kubernetesTunnel, resultErr error) { func startKubernetesConfig(ctx context.Context, config *rest.Config, namespace, resource string, port int) (_ *kubernetesTunnel, resultErr error) {
return startKubernetesConfigWithLocalPort(ctx, config, namespace, resource, port, 0)
}
func startKubernetesConfigWithLocalPort(ctx context.Context, config *rest.Config, namespace, resource string, port, localPort int) (_ *kubernetesTunnel, resultErr error) {
if localPort != 0 {
return startKubernetesConfigOnPort(ctx, config, namespace, resource, port, localPort)
}
first := 49152 + rand.IntN(16384)
for attempt := 0; attempt < 32; attempt++ {
tunnel, err := startKubernetesConfigOnPort(ctx, config, namespace, resource, port, 49152+(first-49152+attempt)%16384)
if err == nil || !errors.Is(err, errKubernetesLocalPort) {
return tunnel, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("%w: no available high port after 32 attempts", errKubernetesLocalPort)
}
var errKubernetesLocalPort = errors.New("Kubernetes local port unavailable")
func startKubernetesConfigOnPort(ctx context.Context, config *rest.Config, namespace, resource string, port, localPort int) (_ *kubernetesTunnel, resultErr error) {
if localPort < 1024 || localPort > 65535 {
return nil, errors.New("Kubernetes local port must be 1024-65535")
}
phaseDone := Begin(ctx, "k8s_resolve") phaseDone := Begin(ctx, "k8s_resolve")
defer func() { phaseDone(resultErr) }() defer func() { phaseDone(resultErr) }()
config = rest.CopyConfig(config) config = rest.CopyConfig(config)
@@ -209,13 +236,14 @@ func startKubernetesConfig(ctx context.Context, config *rest.Config, namespace,
stop := func() { once.Do(func() { cancel(); close(stopCh) }) } stop := func() { once.Do(func() { cancel(); close(stopCh) }) }
url := core.RESTClient().Post().Resource("pods").Namespace(namespace).Name(pod).SubResource("portforward").URL() url := core.RESTClient().Post().Resource("pods").Namespace(namespace).Name(pod).SubResource("portforward").URL()
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: contextTransport{transport, lifetime}}, http.MethodPost, url) dialer := spdy.NewDialer(upgrader, &http.Client{Transport: contextTransport{transport, lifetime}}, http.MethodPost, url)
forwarder, err := portforward.NewOnAddresses(dialer, []string{"127.0.0.1"}, []string{"0:" + strconv.Itoa(remotePort)}, stopCh, ready, io.Discard, io.Discard) forwarder, err := portforward.NewOnAddresses(dialer, []string{"127.0.0.1"}, []string{strconv.Itoa(localPort) + ":" + strconv.Itoa(remotePort)}, stopCh, ready, io.Discard, io.Discard)
if err != nil { if err != nil {
stop() stop()
return nil, errors.New("Kubernetes port-forward initialization failed") return nil, errors.New("Kubernetes port-forward initialization failed")
} }
done := make(chan struct{}) done := make(chan struct{})
go func() { defer close(done); defer stop(); _ = forwarder.ForwardPorts() }() var forwardErr error
go func() { defer close(done); defer stop(); forwardErr = forwarder.ForwardPorts() }()
owned := true owned := true
defer func() { defer func() {
if owned { if owned {
@@ -227,6 +255,9 @@ func startKubernetesConfig(ctx context.Context, config *rest.Config, namespace,
case <-ctx.Done(): case <-ctx.Done():
return nil, fmt.Errorf("Kubernetes port-forward setup: %w", ctx.Err()) return nil, fmt.Errorf("Kubernetes port-forward setup: %w", ctx.Err())
case <-done: case <-done:
if forwardErr != nil && strings.Contains(forwardErr.Error(), "unable to listen on any of the requested ports") {
return nil, fmt.Errorf("%w: 127.0.0.1:%d; port is occupied or binding is denied", errKubernetesLocalPort, localPort)
}
return nil, errors.New("Kubernetes port-forward startup failed; check API access and pods/portforward permission") return nil, errors.New("Kubernetes port-forward startup failed; check API access and pods/portforward permission")
case <-ready: case <-ready:
} }
@@ -4,9 +4,12 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"errors"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
@@ -95,10 +98,55 @@ func (f *persistentKubernetesFixture) open(t *testing.T) *kubernetesTunnel {
return tunnel return tunnel
} }
func TestKubernetesFixedLocalPort(t *testing.T) {
f := newPersistentKubernetesFixture(t)
available := f.open(t)
_, rawPort, _ := net.SplitHostPort(available.address)
port, _ := strconv.Atoi(rawPort)
available.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tunnel, err := startKubernetesConfigWithLocalPort(ctx, f.config, "testing", "statefulset/mongo", 3306, port)
if err != nil {
t.Fatal(err)
}
defer tunnel.Close()
if tunnel.address != net.JoinHostPort("127.0.0.1", rawPort) {
t.Fatalf("address = %s", tunnel.address)
}
c, err := tunnel.Dial(ctx, "tcp", "target:3306")
if err != nil {
t.Fatal(err)
}
if err := persistentEcho(c, "fixed-port"); err != nil {
t.Fatal(err)
}
c.Close()
other, err := startKubernetesConfigWithLocalPort(ctx, f.config, "testing", "statefulset/mongo", 3306, port)
if other != nil {
other.Close()
t.Fatal("accepted occupied fixed port")
}
if !errors.Is(err, errKubernetesLocalPort) {
t.Fatalf("collision error: %v", err)
}
tunnel.Close()
listener, err := net.Listen("tcp", tunnel.address)
if err != nil {
t.Fatalf("port not released: %v", err)
}
listener.Close()
}
func TestKubernetesPersistentMultiplexLifecycle(t *testing.T) { func TestKubernetesPersistentMultiplexLifecycle(t *testing.T) {
f := newPersistentKubernetesFixture(t) f := newPersistentKubernetesFixture(t)
tunnel := f.open(t) tunnel := f.open(t)
<-f.connections <-f.connections
_, rawPort, _ := net.SplitHostPort(tunnel.address)
port, _ := strconv.Atoi(rawPort)
if port < 49152 || port > 65535 {
t.Fatalf("not a high port: %d", port)
}
exercisePersistentTunnel(t, tunnel, f.upgrades.Load) exercisePersistentTunnel(t, tunnel, f.upgrades.Load)
waitPersistentDone(t, f.closed) waitPersistentDone(t, f.closed)
} }
@@ -0,0 +1,55 @@
package mysqlproxy
import (
"strings"
"github.com/pingcap/tidb/pkg/parser"
"github.com/pingcap/tidb/pkg/parser/ast"
parsermysql "github.com/pingcap/tidb/pkg/parser/mysql"
"github.com/pingcap/tidb/pkg/parser/opcode"
_ "github.com/pingcap/tidb/pkg/parser/test_driver"
)
// Parse the original text in the same mode used by the target. Limit this
// compatibility path to VALUES data; it must never admit another statement,
// dynamic expression, or server-executed comment hidden by quote differences.
func safeANSIQuotedInsert(query string) (safe bool) {
defer func() {
if recover() != nil {
safe = false
}
}()
if !strings.Contains(query, `"`) || strings.Contains(query, "/*!") ||
strings.Contains(query, "/*+") || strings.Contains(strings.ToUpper(query), "/*M!") {
return false
}
p := parser.New()
p.SetSQLMode(parsermysql.ModeANSIQuotes | parsermysql.ModeStrictTransTables | parsermysql.ModeNoEngineSubstitution)
statements, warnings, err := p.ParseSQL(query)
if err != nil || len(warnings) != 0 || len(statements) != 1 {
return false
}
insert, ok := statements[0].(*ast.InsertStmt)
if !ok || insert.IsReplace || insert.Select != nil || insert.Setlist || len(insert.Lists) == 0 ||
len(insert.OnDuplicate) != 0 || len(insert.TableHints) != 0 || len(insert.Returning) != 0 ||
insert.RowAlias.O != "" || len(insert.ColumnAliases) != 0 {
return false
}
for _, row := range insert.Lists {
for _, value := range row {
switch v := value.(type) {
case ast.ValueExpr, ast.ParamMarkerExpr:
case *ast.UnaryOperationExpr:
if v.Op != opcode.Plus && v.Op != opcode.Minus {
return false
}
if _, ok := v.V.(ast.ValueExpr); !ok {
return false
}
default:
return false
}
}
}
return true
}
@@ -0,0 +1,310 @@
package mysqlproxy
import (
"context"
"errors"
"net"
"strconv"
"strings"
"testing"
"time"
"github.com/go-mysql-org/go-mysql/client"
"github.com/go-mysql-org/go-mysql/mysql"
"github.com/go-mysql-org/go-mysql/server"
"linkport/internal/model"
)
const quotedInsert = `INSERT INTO "shopee-wes-test"."ttx_action_right" ("tid", "structure", "version", "createdBy") VALUES ('ttx.wso.Bill', '[{"id":"bill:create","name":"save","checked":true}]', '0', NULL), ('other', 'it\'s a JSON "string"; DELETE FROM t', '-1', NULL);`
func TestANSIQuotedInsertPolicy(t *testing.T) {
for _, query := range []string{
quotedInsert,
`INSERT INTO "t" ("n") VALUES (-1), (+2), (NULL), (TRUE)`,
`INSERT INTO "t" ("n") VALUES (?)`,
`INSERT INTO "a""b; DELETE FROM t" ("n") VALUES ('value')`,
`INSERT INTO "t" ("n") VALUES ('` + strings.Repeat("x", (1<<20)+1) + `')`,
`INSERT INTO "t" ("n") VALUES ` + strings.Repeat("(1),", 12000) + `(1)`,
} {
ansi, err := checkWriteSafety(query)
if err != nil || !ansi {
t.Fatalf("quoted VALUES rejected: ansi=%t err=%v query=%.120s", ansi, err, query)
}
if err := CheckReadOnly(query); err == nil {
t.Fatal("readonly accepted quoted insert")
}
}
for _, query := range []string{
`INSERT INTO "t" VALUES (1); DELETE FROM t`,
`INSERT INTO "t" VALUES (1); SELECT 1`,
`INSERT INTO "t" VALUES (1); INSERT INTO "t" VALUES (2)`,
`INSERT INTO "t" VALUES (1) /*!; DELETE FROM t */`,
`INSERT INTO "t" VALUES (1) /*M!; DELETE FROM t */`,
`INSERT /*+ SET_VAR(sql_mode='') */ INTO "t" VALUES (1)`,
`INSERT INTO "t" VALUES ("string")`,
`INSERT INTO "t" VALUES (sleep(1))`,
`INSERT INTO "t" VALUES ((SELECT 1))`,
`INSERT INTO "t" VALUES (@x := 1)`,
`INSERT INTO "t" VALUES (@x)`,
`INSERT INTO "t" SELECT 1`,
`INSERT INTO "t" VALUES (1) ON DUPLICATE KEY UPDATE n=2`,
`REPLACE INTO "t" VALUES (1)`,
`INSERT INTO "t" VALUES ('unfinished`,
"INSERT INTO \"t\" VALUES (1)\x00; DELETE FROM t",
"INSERT INTO \"t\" VALUES (1)\x1a; DELETE FROM t",
`UPDATE "t" SET "n"=1`, `DELETE FROM "t"`,
`SET sql_mode='ANSI_QUOTES'`,
} {
if err := CheckWriteSafety(query); err == nil {
t.Fatalf("unverifiable quoted SQL accepted: %s", query)
}
}
ansi, err := checkWriteSafety(`INSERT INTO t VALUES ("ordinary double-quoted string")`)
if err != nil || ansi {
t.Fatalf("ordinary string changed mode: ansi=%t err=%v", ansi, err)
}
}
type ansiRequest struct {
query string
ansi bool
}
// This backend checks wire text and session transitions, without business data.
type ansiBackend struct {
fakeBackend
requests chan ansiRequest
ansi bool
failSQL string
}
func (b *ansiBackend) record(query string) error {
if strings.HasPrefix(query, "SET SESSION sql_mode = '") {
b.ansi = strings.Contains(query, "ANSI_QUOTES")
} else if strings.Contains(query, `"shopee-wes-test"`) && !b.ansi {
return mysql.NewError(mysql.ER_PARSE_ERROR, "ANSI_QUOTES not enabled")
}
b.requests <- ansiRequest{query: query, ansi: b.ansi}
if query == b.failSQL {
return mysql.NewError(mysql.ER_DUP_ENTRY, "fixture duplicate key")
}
return nil
}
func (b *ansiBackend) HandleQuery(query string) (*mysql.Result, error) {
if err := b.record(query); err != nil {
return nil, err
}
return &mysql.Result{AffectedRows: 1, Status: mysql.SERVER_STATUS_AUTOCOMMIT}, nil
}
func (b *ansiBackend) HandleStmtPrepare(query string) (int, int, interface{}, error) {
if err := b.record(query); err != nil {
return 0, 0, nil, err
}
return strings.Count(query, "?"), 0, query, nil
}
func ansiGatewayFixture(t *testing.T, mode, failSQL string) (string, <-chan ansiRequest, <-chan model.Command) {
t.Helper()
requests := make(chan ansiRequest, 128)
records := make(chan model.Command, 128)
ctx, cancel := context.WithCancel(model.WithCommandAudit(context.Background(), func(command model.Command) error {
records <- command
return nil
}))
t.Cleanup(cancel)
backend, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = backend.Close() })
go func() {
raw, err := backend.Accept()
if err != nil {
return
}
defer raw.Close()
handler := &ansiBackend{requests: requests, failSQL: failSQL}
conn, err := newNativeConn(server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil), raw, "target", "secret", handler)
if err != nil {
return
}
for !conn.Closed() {
if conn.HandleCommand() != nil {
return
}
}
}()
gateway, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
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}
go func() {
raw, err := gateway.Accept()
if err == nil {
_ = New().Serve(ctx, raw, p, (&net.Dialer{}).DialContext, nil, nil)
}
}()
return gateway.Addr().String(), requests, records
}
func nextANSIRequest(t *testing.T, requests <-chan ansiRequest) ansiRequest {
t.Helper()
select {
case request := <-requests:
return request
case <-time.After(3 * time.Second):
t.Fatal("missing upstream SQL request")
return ansiRequest{}
}
}
func TestGatewayANSIQuotedInsertPreservesSQLAndMode(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(strconv.FormatBool(fail), func(t *testing.T) {
failSQL := ""
if fail {
failSQL = quotedInsert
}
addr, requests, records := ansiGatewayFixture(t, "readwrite", failSQL)
c, err := client.Connect(addr, "gateway", "gateway-secret", "")
if err != nil {
t.Fatal(err)
}
defer c.Close()
if request := nextANSIRequest(t, requests); request.ansi {
t.Fatal("initial mode enabled ANSI_QUOTES")
}
checkTransitions := func(query string) {
t.Helper()
if request := nextANSIRequest(t, requests); !request.ansi || request.query != "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,ANSI_QUOTES'" {
t.Fatalf("ANSI_QUOTES not enabled: %+v", request)
}
if request := nextANSIRequest(t, requests); !request.ansi || request.query != query {
t.Fatalf("original SQL or mode changed: %+v", request)
}
if request := nextANSIRequest(t, requests); request.ansi || request.query != "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'" {
t.Fatalf("default mode not restored: %+v", request)
}
}
result, err := c.Execute(quotedInsert)
if fail {
var mysqlErr *mysql.MyError
if !errors.As(err, &mysqlErr) || mysqlErr.Code != mysql.ER_DUP_ENTRY {
t.Fatalf("upstream error not preserved: %v", err)
}
} else if err != nil {
t.Fatal(err)
}
if result != nil {
result.Close()
}
checkTransitions(quotedInsert)
query := `INSERT INTO "shopee-wes-test"."ttx_action_right" ("tid") VALUES (?)`
stmt, err := c.Prepare(query)
if err != nil {
t.Fatal(err)
}
checkTransitions(query)
result, err = stmt.Execute("value")
if err != nil {
t.Fatal(err)
}
result.Close()
_ = stmt.Close()
ordinary := `INSERT INTO t VALUES ("ordinary string")`
result, err = c.Execute(ordinary)
if err != nil {
t.Fatal(err)
}
result.Close()
if request := nextANSIRequest(t, requests); request.ansi || request.query != ordinary {
t.Fatalf("subsequent string SQL changed: %+v", request)
}
for _, query := range []string{quotedInsert + ` DELETE FROM t`, `DELETE FROM t`, `INSERT INTO "t" VALUES (sleep(1))`} {
if _, err = c.Execute(query); err == nil {
t.Fatalf("unsafe SQL accepted: %s", query)
}
}
if err = c.Ping(); err != nil {
t.Fatal(err)
}
if len(requests) != 0 {
t.Fatal("rejected SQL reached upstream")
}
found := false
for len(records) > 0 {
command := <-records
if command.Text == quotedInsert && command.Decision == "allowed" {
found = true
}
}
if !found {
t.Fatal("original insert missing from allowed audit")
}
})
}
}
func TestGatewayReadOnlyStillRejectsANSIQuotedInsert(t *testing.T) {
addr, requests, _ := ansiGatewayFixture(t, "readonly", "")
c, err := client.Connect(addr, "gateway", "gateway-secret", "")
if err != nil {
t.Fatal(err)
}
defer c.Close()
_ = nextANSIRequest(t, requests)
for _, prepare := range []bool{false, true} {
if prepare {
_, err = c.Prepare(quotedInsert)
} else {
_, err = c.Execute(quotedInsert)
}
var mysqlErr *mysql.MyError
if !errors.As(err, &mysqlErr) || !strings.Contains(mysqlErr.Message, "readonly_statement") {
t.Fatalf("readonly did not reject insert: %v", err)
}
}
if err = c.Ping(); err != nil || len(requests) != 0 {
t.Fatalf("readonly insert reached upstream or lost connection: %v", err)
}
}
func TestGatewayANSIQuoteModeFailureClosesSession(t *testing.T) {
for _, failSQL := range []string{
"SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,ANSI_QUOTES'",
"SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'",
} {
t.Run(failSQL, func(t *testing.T) {
addr, requests, _ := ansiGatewayFixture(t, "readwrite", failSQL)
c, err := client.Connect(addr, "gateway", "gateway-secret", "")
if err != nil {
t.Fatal(err)
}
defer c.Close()
_ = c.Conn.Conn.SetDeadline(time.Now().Add(3 * time.Second))
_ = nextANSIRequest(t, requests)
result, _ := c.Execute(quotedInsert)
if result != nil {
result.Close()
}
if err = c.Ping(); err == nil {
t.Fatal("connection survived failed parser-mode setup")
}
insertSeen := false
for len(requests) > 0 {
if request := <-requests; request.query == quotedInsert {
insertSeen = true
}
}
if strings.Contains(failSQL, "ANSI_QUOTES") && insertSeen {
t.Fatal("insert forwarded after failed ANSI_QUOTES setup")
}
})
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func binlogGatewayFixture(t *testing.T, mode string, responses [][]byte, audit m
defer stop() defer stop()
_ = raw.SetDeadline(time.Now().Add(5 * time.Second)) _ = raw.SetDeadline(time.Now().Add(5 * time.Second))
s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
c, err := s.NewConn(raw, "target", "secret", &fakeBackend{queries: &atomic.Int32{}}) c, err := newNativeConn(s, raw, "target", "secret", &fakeBackend{queries: &atomic.Int32{}})
if err != nil { if err != nil {
return err return err
} }
@@ -112,7 +112,7 @@ func TestMySQLCompressionNegotiationTrafficAndFallback(t *testing.T) {
raw = compressed raw = compressed
} }
srv := server.NewServer("5.7.44-test", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) srv := server.NewServer("5.7.44-test", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
c, err := srv.NewConn(raw, "target", "secret", &fakeBackend{queries: &queries}) c, err := newNativeConn(srv, raw, "target", "secret", &fakeBackend{queries: &queries})
if err != nil { if err != nil {
return return
} }
@@ -35,7 +35,7 @@ func TestCharsetSentInHandshake(testContext *testing.T) {
defer backend.Close() defer backend.Close()
backend.SetDeadline(time.Now().Add(3 * time.Second)) backend.SetDeadline(time.Now().Add(3 * time.Second))
fixture := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) fixture := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
connection, err := fixture.NewConn(backend, "fixture", "password", &fakeBackend{queries: &atomic.Int32{}}) connection, err := newNativeConn(fixture, backend, "fixture", "password", &fakeBackend{queries: &atomic.Int32{}})
if err != nil { if err != nil {
return return
} }
@@ -29,7 +29,7 @@ func TestDefaultDatabaseAndClientOverride(t *testing.T) {
go func() { go func() {
defer s.Close() defer s.Close()
srv := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) srv := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
conn, err := srv.NewConn(s, "target", "secret", &databaseBackend{selected: selected}) conn, err := newNativeConn(srv, s, "target", "secret", &databaseBackend{selected: selected})
if err == nil { if err == nil {
for conn.HandleCommand() == nil { for conn.HandleCommand() == nil {
} }
+41 -6
View File
@@ -87,14 +87,21 @@ func open(ctx context.Context, p model.Proxy, dial model.DialFunc, db string) (*
if e != nil { if e != nil {
return nil, e return nil, e
} }
if c == nil {
return nil, errors.New("target dial returned a nil connection")
}
raw = c raw = c
_ = c.SetDeadline(time.Now().Add(p.ConnectionOptions.ConnectTimeout(15 * time.Second))) _ = c.SetDeadline(time.Now().Add(p.ConnectionOptions.ConnectTimeout(15 * time.Second)))
stop = context.AfterFunc(ctx, func() { _ = c.Close() }) // Capture the original transport. Compression negotiation may replace c
// with a wrapper or nil on error before the cancellation callback runs.
stop = context.AfterFunc(ctx, func() { _ = raw.Close() })
if p.TargetCompression == "zlib" { if p.TargetCompression == "zlib" {
var err error var err error
c, compress, err = peekCompression(c) c, compress, err = peekCompression(c)
if err != nil { if err != nil {
_ = raw.Close() if raw != nil {
_ = raw.Close()
}
return nil, err return nil, err
} }
} }
@@ -238,6 +245,7 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
} }
cmd := data[0] cmd := data[0]
valid := true valid := true
ansiQuotes := false
var batch []string var batch []string
rule := "unsupported_command" rule := "unsupported_command"
switch cmd { switch cmd {
@@ -247,14 +255,16 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
if p.AccessMode == "unrestricted" { if p.AccessMode == "unrestricted" {
break // Preserve SQL text and let the authenticated target enforce it. break // Preserve SQL text and let the authenticated target enforce it.
} }
if safetyErr := CheckWriteSafety(string(data[1:])); safetyErr != nil { var safetyErr error
ansiQuotes, safetyErr = checkWriteSafety(string(data[1:]))
if safetyErr != nil {
valid = false valid = false
rule = "unsafe_sql" rule = "unsafe_sql"
if errors.Is(safetyErr, errMissingWhere) { if errors.Is(safetyErr, errMissingWhere) {
rule = "missing_where" rule = "missing_where"
} }
} }
if cmd == mysql.COM_QUERY { if cmd == mysql.COM_QUERY && !ansiQuotes {
pieces, splitErr := sqlparser.NewTestParser().SplitStatementToPieces(string(data[1:])) pieces, splitErr := sqlparser.NewTestParser().SplitStatementToPieces(string(data[1:]))
if splitErr == nil && len(pieces) > 1 { if splitErr == nil && len(pieces) > 1 {
batch = pieces batch = pieces
@@ -342,6 +352,11 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
downstream.ResetSequence() downstream.ResetSequence()
continue continue
} }
if ansiQuotes {
if e = setIdentifierQuoteMode(up, true); e != nil {
return errors.New("failed to enable SQL identifier quoting")
}
}
up.ResetSequence() up.ResetSequence()
if e = up.WritePacket(append(make([]byte, 4), data...)); e != nil { if e = up.WritePacket(append(make([]byte, 4), data...)); e != nil {
return e return e
@@ -378,6 +393,11 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m
} }
} }
} }
if ansiQuotes {
if e = setIdentifierQuoteMode(up, false); e != nil {
return errors.New("failed to restore SQL safety parser mode")
}
}
if e = finishResult(); e != nil { if e = finishResult(); e != nil {
return e return e
} }
@@ -533,6 +553,18 @@ func setParserMode(c *client.Conn, options model.ConnectionOptions) error {
query := "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', " + query := "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION', " +
"character_set_client = '" + charset + "', character_set_connection = '" + charset + "', " + "character_set_client = '" + charset + "', character_set_connection = '" + charset + "', " +
"character_set_results = '" + charset + "', collation_connection = '" + collation + "'" "character_set_results = '" + charset + "', collation_connection = '" + collation + "'"
return executeParserSetup(c, query)
}
func setIdentifierQuoteMode(c *client.Conn, ansiQuotes bool) error {
mode := "STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION"
if ansiQuotes {
mode += ",ANSI_QUOTES"
}
return executeParserSetup(c, "SET SESSION sql_mode = '"+mode+"'")
}
func executeParserSetup(c *client.Conn, query string) error {
c.ResetSequence() c.ResetSequence()
b := append([]byte{0, 0, 0, 0, mysql.COM_QUERY}, []byte(query)...) b := append([]byte{0, 0, 0, 0, mysql.COM_QUERY}, []byte(query)...)
if e := c.WritePacket(b); e != nil { if e := c.WritePacket(b); e != nil {
@@ -580,13 +612,16 @@ func (p *accountProvider) CheckUsername(username string) (bool, error) {
return found, err return found, err
} }
func (p *accountProvider) GetCredential(username string) (string, bool, error) { func (p *accountProvider) GetCredential(username string) (server.Credential, bool, error) {
p.username = username p.username = username
a, found := transport.Account(p.proxy, username, p.remote) a, found := transport.Account(p.proxy, username, p.remote)
p.account = a p.account = a
return a.Password, found, nil return server.Credential{Passwords: []string{a.Password}, AuthPluginName: mysql.AUTH_NATIVE_PASSWORD}, found, nil
} }
func (p *accountProvider) OnAuthSuccess(*server.Conn) error { return nil }
func (p *accountProvider) OnAuthFailure(*server.Conn, error) {}
func (c *clientGreetingConn) Write(p []byte) (int, error) { func (c *clientGreetingConn) Write(p []byte) (int, error) {
if !c.sent && len(p) > 5 { if !c.sent && len(p) > 5 {
c.sent = true c.sent = true
+1 -1
View File
@@ -97,7 +97,7 @@ func testGatewayTLS(t *testing.T, mode string, certificate bool, audit ...model.
go func() { go func() {
defer r.Close() defer r.Close()
s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
c, e := s.NewConn(r, "target", "secret", &fakeBackend{queries: queries}) c, e := newNativeConn(s, r, "target", "secret", &fakeBackend{queries: queries})
if e != nil { if e != nil {
return return
} }
+1 -1
View File
@@ -31,7 +31,7 @@ func TestHealthRequiresPingResponse(t *testing.T) {
defer raw.Close() defer raw.Close()
raw.SetDeadline(time.Now().Add(3 * time.Second)) raw.SetDeadline(time.Now().Add(3 * time.Second))
s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) s := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
c, err := s.NewConn(raw, "target", "secret", &fakeBackend{queries: &atomic.Int32{}}) c, err := newNativeConn(s, raw, "target", "secret", &fakeBackend{queries: &atomic.Int32{}})
if err != nil { if err != nil {
return return
} }
@@ -0,0 +1,16 @@
package mysqlproxy
import (
"net"
"github.com/go-mysql-org/go-mysql/mysql"
"github.com/go-mysql-org/go-mysql/server"
)
func newNativeConn(s *server.Server, conn net.Conn, user, password string, h server.Handler) (*server.Conn, error) {
auth := server.NewInMemoryAuthenticationHandler(mysql.AUTH_NATIVE_PASSWORD)
if err := auth.AddUser(user, password); err != nil {
return nil, err
}
return s.NewCustomizedConn(conn, auth, h)
}
@@ -273,7 +273,7 @@ func preparedFixture(t *testing.T) (model.Proxy, model.DialFunc, *atomic.Int32)
defer s.Close() defer s.Close()
s.SetDeadline(time.Now().Add(10 * time.Second)) s.SetDeadline(time.Now().Add(10 * time.Second))
srv := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil) srv := server.NewServer("8.0.11", 45, mysql.AUTH_NATIVE_PASSWORD, nil, nil)
conn, err := srv.NewConn(s, "target", "secret", &databaseBackend{selected: make(chan string, 10)}) conn, err := newNativeConn(srv, s, "target", "secret", &databaseBackend{selected: make(chan string, 10)})
if err == nil { if err == nil {
for conn.HandleCommand() == nil { for conn.HandleCommand() == nil {
} }
@@ -15,6 +15,20 @@ var errWriteSafety = errors.New("request denied by SQL write safety policy")
// connection creation and reset. Server-side routines are a trusted boundary: // connection creation and reset. Server-side routines are a trusted boundary:
// this policy inspects submitted SQL, not SQL executed inside those routines. // this policy inspects submitted SQL, not SQL executed inside those routines.
func CheckWriteSafety(query string) (err error) { func CheckWriteSafety(query string) (err error) {
_, err = checkWriteSafety(query)
return err
}
// The boolean selects ANSI_QUOTES only for a fully validated INSERT VALUES.
func checkWriteSafety(query string) (ansiQuotes bool, err error) {
err = checkMySQLWriteSafety(query)
if errors.Is(err, errWriteSafety) && safeANSIQuotedInsert(query) {
return true, nil
}
return false, err
}
func checkMySQLWriteSafety(query string) (err error) {
defer func() { defer func() {
if recover() != nil { if recover() != nil {
err = errWriteSafety err = errWriteSafety
@@ -119,8 +119,8 @@ func TestWriteSafetyDoesNotExposeSQL(t *testing.T) {
func TestWriteSafetyLargeInsert(t *testing.T) { func TestWriteSafetyLargeInsert(t *testing.T) {
for name, query := range map[string]string{ for name, query := range map[string]string{
"text_over_1MiB": "INSERT INTO shipment_header(payload) VALUES ('" + strings.Repeat("x", (1<<20)+1) + "')", "text_over_1MiB": "INSERT INTO shipment_header(payload) VALUES ('" + strings.Repeat("x", (1<<20)+1) + "')",
"many_values": "INSERT INTO shipment_header(id) VALUES " + strings.Repeat("(1),", 12000) + "(1)", "many_values": "INSERT INTO shipment_header(id) VALUES " + strings.Repeat("(1),", 12000) + "(1)",
"many_placeholders": "INSERT INTO shipment_header(id) VALUES " + strings.Repeat("(?),", 12000) + "(?)", "many_placeholders": "INSERT INTO shipment_header(id) VALUES " + strings.Repeat("(?),", 12000) + "(?)",
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
export GOTOOLCHAIN=go1.25.0 export GOTOOLCHAIN=go1.27.1
export CGO_ENABLED=0 export CGO_ENABLED=0
build_jobs="${BUILD_JOBS:-1}" build_jobs="${BUILD_JOBS:-1}"
build_parallelism="${GO_BUILD_PARALLELISM:-1}" build_parallelism="${GO_BUILD_PARALLELISM:-1}"
+1 -1
View File
@@ -13,7 +13,7 @@ function Write-BuildPhase([string]$Name) {
. (Join-Path $PSScriptRoot 'version.ps1') . (Join-Path $PSScriptRoot 'version.ps1')
$buildInfo = Get-linkportBuildInfo -Version $Version $buildInfo = Get-linkportBuildInfo -Version $Version
Set-Location -LiteralPath (Split-Path $PSScriptRoot -Parent) Set-Location -LiteralPath (Split-Path $PSScriptRoot -Parent)
$env:GOTOOLCHAIN = 'go1.25.0' $env:GOTOOLCHAIN = 'go1.27.1'
$env:GO111MODULE = 'on' $env:GO111MODULE = 'on'
. (Join-Path $PSScriptRoot 'go-toolchain.ps1') . (Join-Path $PSScriptRoot 'go-toolchain.ps1')
$goExecutable = Get-LinkPortGoExecutable -ProjectRoot (Split-Path $PSScriptRoot -Parent) $goExecutable = Get-LinkPortGoExecutable -ProjectRoot (Split-Path $PSScriptRoot -Parent)
+1 -1
View File
@@ -2,7 +2,7 @@
set -eu set -eu
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
export PATH="$PWD/tools/go/bin:$PWD/tools/node/bin:$PATH" export PATH="$PWD/tools/go/bin:$PWD/tools/node/bin:$PATH"
export GOTOOLCHAIN=go1.25.0 export GOTOOLCHAIN=go1.27.1
export GO111MODULE=on export GO111MODULE=on
skip_tests=0 skip_tests=0
rebuild_web=0 rebuild_web=0
+1 -1
View File
@@ -18,7 +18,7 @@ function Get-LinkPortGoExecutable {
if ($command -and (Test-Path -LiteralPath $command.Source -PathType Leaf)) { return $command.Source } if ($command -and (Test-Path -LiteralPath $command.Source -PathType Leaf)) { return $command.Source }
throw (@( throw (@(
'Go was not found. Running start-dev.cmd from a source checkout requires Go, Node.js and npm.' 'Go was not found. Running start-dev.cmd from a source checkout requires Go, Node.js and npm.'
'Install Go 1.25.0 on PATH and reopen the terminal, or set LINKPORT_GO to a project-relative go.exe path.' 'Install Go 1.27.1 on PATH and reopen the terminal, or set LINKPORT_GO to a project-relative go.exe path.'
'A portable Go installation may be placed at tools\go\bin\go.exe relative to the project.' 'A portable Go installation may be placed at tools\go\bin\go.exe relative to the project.'
'To run without development tools, use the prebuilt linkport.exe release instead of start-dev.cmd.' 'To run without development tools, use the prebuilt linkport.exe release instead of start-dev.cmd.'
('Checked tools/go/bin/go.exe under project: ' + $ProjectRoot + ', then go.exe on PATH.') ('Checked tools/go/bin/go.exe under project: ' + $ProjectRoot + ', then go.exe on PATH.')
+1 -1
View File
@@ -45,7 +45,7 @@ try {
$script:commandSource = $null $script:commandSource = $null
$missingMessage = '' $missingMessage = ''
try { Get-LinkPortGoExecutable $project | Out-Null } catch { $missingMessage = $_.Exception.Message } try { Get-LinkPortGoExecutable $project | Out-Null } catch { $missingMessage = $_.Exception.Message }
Assert-GoTest ($missingMessage.Contains('Go 1.25.0') -and $missingMessage.Contains('LINKPORT_GO') -and $missingMessage.Contains('prebuilt linkport.exe') -and $missingMessage.Contains($project)) 'Missing Go guidance is incomplete' Assert-GoTest ($missingMessage.Contains('Go 1.27.1') -and $missingMessage.Contains('LINKPORT_GO') -and $missingMessage.Contains('prebuilt linkport.exe') -and $missingMessage.Contains($project)) 'Missing Go guidance is incomplete'
$portable = New-GoFixture (Join-Path $project 'tools/go/bin/go.exe') $portable = New-GoFixture (Join-Path $project 'tools/go/bin/go.exe')
Assert-GoTest ((Get-LinkPortGoExecutable $project) -eq $portable) 'Project portable Go lookup failed' Assert-GoTest ((Get-LinkPortGoExecutable $project) -eq $portable) 'Project portable Go lookup failed'
$script:commandSource = $pathGo $script:commandSource = $pathGo
+1 -1
View File
@@ -31,7 +31,7 @@ $keepBytes = [byte[]]@()
if ($hadKeep) { $keepBytes = [IO.File]::ReadAllBytes($keepPath) } if ($hadKeep) { $keepBytes = [IO.File]::ReadAllBytes($keepPath) }
Push-Location -LiteralPath $repo Push-Location -LiteralPath $repo
try { try {
$env:GOTOOLCHAIN = 'go1.25.0' $env:GOTOOLCHAIN = 'go1.27.1'
$env:GO111MODULE = 'on' $env:GO111MODULE = 'on'
$env:LINKPORT_NO_BROWSER = '1' $env:LINKPORT_NO_BROWSER = '1'
$env:GOOS = 'windows' $env:GOOS = 'windows'
+1 -1
View File
@@ -14,7 +14,7 @@ foreach ($name in $names) { $previous[$name] = [Environment]::GetEnvironmentVari
New-Item -ItemType Directory -Path $tempDir | Out-Null New-Item -ItemType Directory -Path $tempDir | Out-Null
Push-Location -LiteralPath $repo Push-Location -LiteralPath $repo
try { try {
$env:GOTOOLCHAIN = 'go1.25.0' $env:GOTOOLCHAIN = 'go1.27.1'
docker compose -f $compose -p $project up -d --build --wait --wait-timeout 300 docker compose -f $compose -p $project up -d --build --wait --wait-timeout 300
if ($LASTEXITCODE -ne 0) { throw 'Fixture startup failed' } if ($LASTEXITCODE -ne 0) { throw 'Fixture startup failed' }
docker compose -f $compose -p $project run --rm --no-deps mongo80-init docker compose -f $compose -p $project run --rm --no-deps mongo80-init
+1 -1
View File
@@ -4,7 +4,7 @@ cd "$(dirname "$0")/.."
export PATH="$PWD/tools/go/bin:$PWD/tools/node/bin:$PATH" export PATH="$PWD/tools/go/bin:$PWD/tools/node/bin:$PATH"
command -v docker >/dev/null command -v docker >/dev/null
command -v go >/dev/null command -v go >/dev/null
export GOTOOLCHAIN=go1.25.0 export GOTOOLCHAIN=go1.27.1
matrix_tmp="$(mktemp -d)" matrix_tmp="$(mktemp -d)"
matrix_project="linkport-matrix-$(date +%s)-$$" matrix_project="linkport-matrix-$(date +%s)-$$"
compose=(docker compose -f deploy/compose.test.yml -p "$matrix_project") compose=(docker compose -f deploy/compose.test.yml -p "$matrix_project")
+2 -23
View File
@@ -34,6 +34,7 @@ import AssetSelect from './components/AssetSelect.vue';
import VersionInfo from './components/VersionInfo.vue'; import VersionInfo from './components/VersionInfo.vue';
import TargetAccounts from './components/TargetAccounts.vue'; import TargetAccounts from './components/TargetAccounts.vue';
import GatewayAccounts from './components/GatewayAccounts.vue'; import GatewayAccounts from './components/GatewayAccounts.vue';
import GatewayAccessMode from './components/GatewayAccessMode.vue';
import GatewayTCPForward from './components/GatewayTCPForward.vue'; import GatewayTCPForward from './components/GatewayTCPForward.vue';
import TargetDraftTest from './components/TargetDraftTest.vue'; import TargetDraftTest from './components/TargetDraftTest.vue';
import GatewayConnectionTest from './components/GatewayConnectionTest.vue'; import GatewayConnectionTest from './components/GatewayConnectionTest.vue';
@@ -1467,29 +1468,7 @@ onUnmounted(() => {
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="!editingTarget" label="网关与访问" name="gateway"> <el-tab-pane v-if="!editingTarget" label="网关与访问" name="gateway">
<GatewayListenCheck :value="form" :api="api" /> <GatewayListenCheck :value="form" :api="api" />
<template v-if="['mysql','mongodb'].includes(form.type)"><div class="setting-row"> <GatewayAccessMode v-if="['mysql','mongodb'].includes(form.type)" v-model="form.accessMode" :protocol="form.type" />
<div>
<strong>
只读模式
<span class="optional">可选</span>
</strong>
<p>{{ form.type === 'mysql' ? '启用后所有账号只读;关闭后按账号选择只读、安全读写或普通读写,默认安全读写。' : '默认关闭。启用后所有账号仅允许严格白名单内的读取操作。' }}</p>
</div>
<el-switch
aria-label="只读模式"
:model-value="form.accessMode === 'readonly'"
@update:model-value="
form.accessMode = $event ? 'readonly' : 'readwrite'
"
/>
</div>
<el-alert
v-if="form.accessMode === 'readonly'"
title="未知语法、函数和命令将被拒绝;网关不改变数据库权限,无法保证阻止服务端内部隐藏副作用。"
type="warning"
:closable="false"
/>
</template>
<div class="form-grid"> <div class="form-grid">
<el-form-item label="监听 IP" data-field="listenHost" :error="fieldIssues.find(i => i.path === 'listenHost')?.message"> <el-form-item label="监听 IP" data-field="listenHost" :error="fieldIssues.find(i => i.path === 'listenHost')?.message">
<el-input v-model="form.listenHost" /> <el-input v-model="form.listenHost" />
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { accessModeOptions } from '../domain/accessMode';
defineProps<{ protocol: string; modelValue?: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
</script>
<template>
<el-form-item v-if="protocol === 'mysql'" label="网关访问模式" data-field="accessMode">
<el-select :model-value="modelValue || 'readwrite'" aria-label="网关访问模式" @update:model-value="emit('update:modelValue', $event)">
<el-option v-for="option in accessModeOptions(protocol)" :key="option.value" :value="option.value" :label="option.label" />
</el-select>
</el-form-item>
<div v-else class="setting-row">
<strong>只读模式</strong>
<el-switch aria-label="只读模式" :model-value="modelValue === 'readonly'" @update:model-value="emit('update:modelValue', $event ? 'readonly' : 'readwrite')" />
</div>
<el-alert v-if="modelValue === 'readonly'" title="所有账号仅允许读取请求。" type="info" :closable="false" />
</template>
+6 -7
View File
@@ -7,6 +7,7 @@ import { mappingLabel, mappingSelectionError } from '../domain/accountMapping';
import { proxyText } from '../domain/proxyLabels'; import { proxyText } from '../domain/proxyLabels';
import { targetAccountRequired } from '../domain/targetAuth'; import { targetAccountRequired } from '../domain/targetAuth';
import { accountRemark, accountText } from '../domain/accountLabels'; import { accountRemark, accountText } from '../domain/accountLabels';
import { accessModeLabel, accessModeOptions } from '../domain/accessMode';
const props = defineProps<{ accounts: any[]; protocol: string; gatewayAccessMode?: string; targets?: any[]; assets?: any[] }>(); const props = defineProps<{ accounts: any[]; protocol: string; gatewayAccessMode?: string; targets?: any[]; assets?: any[] }>();
const root = ref<HTMLElement>(); const root = ref<HTMLElement>();
const selectedIndex = ref(0); const selectedIndex = ref(0);
@@ -40,7 +41,7 @@ function targetLabel(account:any) {
} }
async function add() { async function add() {
if (props.accounts.length >= 32) return; if (props.accounts.length >= 32) return;
props.accounts.push({ id: crypto.randomUUID(), username: '', password: '', disabled: false, accessMode: ['ssh','sftp'].includes(props.protocol) ? undefined : 'readwrite', allowCIDRs: [], authorizedKeys: [] }); props.accounts.push({ id: crypto.randomUUID(), username: '', password: '', disabled: false, accessMode: ['mysql','ssh','sftp'].includes(props.protocol) ? undefined : 'readwrite', allowCIDRs: [], authorizedKeys: [] });
selectedIndex.value = props.accounts.length - 1; selectedIndex.value = props.accounts.length - 1;
await selectAccount(); await selectAccount();
root.value?.querySelector<HTMLInputElement>('[data-account-username] input')?.focus({ preventScroll: true }); root.value?.querySelector<HTMLInputElement>('[data-account-username] input')?.focus({ preventScroll: true });
@@ -89,15 +90,13 @@ async function remove() {
<div class="policy-controls"> <div class="policy-controls">
<el-form-item label="禁用账号"><el-switch v-model="account.disabled" aria-label="禁用账号" /></el-form-item> <el-form-item label="禁用账号"><el-switch v-model="account.disabled" aria-label="禁用账号" /></el-form-item>
<el-form-item v-if="['mysql', 'mongodb'].includes(protocol)" label="访问模式" :data-field="`gatewayAccounts.${account.id}.accessMode`"> <el-form-item v-if="['mysql', 'mongodb'].includes(protocol)" label="访问模式" :data-field="`gatewayAccounts.${account.id}.accessMode`">
<el-select :model-value="account.accessMode || gatewayAccessMode || 'readwrite'" aria-label="账号访问模式" @update:model-value="account.accessMode = $event"> <el-select :model-value="protocol === 'mysql' ? account.accessMode || 'inherit' : account.accessMode || gatewayAccessMode || 'readwrite'" aria-label="账号访问模式" @update:model-value="account.accessMode = $event === 'inherit' ? '' : $event">
<el-option value="readonly" label="只读" /> <el-option v-if="protocol === 'mysql'" value="inherit" :label="`继承网关(${accessModeLabel(protocol, gatewayAccessMode)})`" />
<el-option value="readwrite" :label="protocol === 'mysql' ? '安全读写(默认)' : '读写(默认)'" /> <el-option v-for="option in accessModeOptions(protocol)" :key="option.value" :value="option.value" :label="option.label" />
<el-option v-if="protocol === 'mysql'" value="unrestricted" label="普通读写" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
<p v-if="gatewayAccessMode === 'readonly'" class="form-help">网关已开启只读,所有账号均按只读模式执行。</p> <p v-if="gatewayAccessMode === 'readonly'" class="form-help">所有账号仅允许读取请求。</p>
<p v-else-if="protocol === 'mysql'" class="form-help mode-help">{{ (account.accessMode || gatewayAccessMode) === 'unrestricted' ? '不检查 WHERE,SQL 权限由上游数据库控制。' : account.accessMode === 'readonly' ? '仅允许读取操作,包括 Binlog 读取;上游仍需授予权限。' : 'UPDATE / DELETE 必须带 WHERE;常用协议命令按权限转发。' }}</p>
<SourceAllowlist :data-field="`gatewayAccounts.${account.id}.allowCIDRs`" v-model="account.allowCIDRs" scope="account" /> <SourceAllowlist :data-field="`gatewayAccounts.${account.id}.allowCIDRs`" v-model="account.allowCIDRs" scope="account" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane v-if="['ssh','sftp'].includes(protocol)" label="公钥" name="keys"> <el-tab-pane v-if="['ssh','sftp'].includes(protocol)" label="公钥" name="keys">
+11
View File
@@ -19,6 +19,10 @@ let applyingContext = false;
const fieldPath = (name: string) => `${props.fieldPrefix || 'outbound'}.${name}`; const fieldPath = (name: string) => `${props.fieldPrefix || 'outbound'}.${name}`;
const fieldError = (name: string) => props.issues?.find(issue => issue.path === fieldPath(name))?.message; const fieldError = (name: string) => props.issues?.find(issue => issue.path === fieldPath(name))?.message;
const isTCPForward = computed(() => props.fieldPrefix === 'tcpForward'); const isTCPForward = computed(() => props.fieldPrefix === 'tcpForward');
const localPortMode = computed({
get: () => props.value.localPort ? 'fixed' : 'auto',
set: (mode: string) => { props.value.localPort = mode === 'fixed' ? 55000 : undefined; },
});
const validPort = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535; const validPort = (value: unknown): value is number => typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535;
function normalizePorts(value: unknown): number[] { function normalizePorts(value: unknown): number[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
@@ -238,6 +242,13 @@ async function runPreflight() {
</div> </div>
<p v-else-if="isTCPForward && selectedResource" class="form-help" role="status">当前资源未声明 TCP 端口,请在下方手动填写资源端口。</p> <p v-else-if="isTCPForward && selectedResource" class="form-help" role="status">当前资源未声明 TCP 端口,请在下方手动填写资源端口。</p>
<slot v-if="isTCPForward" name="port" /> <slot v-if="isTCPForward" name="port" />
<el-form-item v-else label="本机监听端口" :data-field="fieldPath('localPort')" :error="fieldError('localPort')">
<el-radio-group v-model="localPortMode" aria-label="本机端口模式">
<el-radio-button value="auto">自动高位端口</el-radio-button>
<el-radio-button value="fixed">固定端口</el-radio-button>
</el-radio-group>
<el-input-number v-if="localPortMode === 'fixed'" v-model="value.localPort" aria-label="固定本机端口" :min="1024" :max="65535" :step="1" controls-position="right" />
</el-form-item>
<section v-if="isTCPForward" class="kubernetes-preflight" :aria-busy="preflightBusy"> <section v-if="isTCPForward" class="kubernetes-preflight" :aria-busy="preflightBusy">
<el-button :loading="preflightBusy" :disabled="!preflightReady" @click="runPreflight">预检 Kubernetes 权限</el-button> <el-button :loading="preflightBusy" :disabled="!preflightReady" @click="runPreflight">预检 Kubernetes 权限</el-button>
<p class="form-help">检查读取资源、解析 Pod 和建立 pods/portforward 所需权限;结果仅供启动前参考。</p> <p class="form-help">检查读取资源、解析 Pod 和建立 pods/portforward 所需权限;结果仅供启动前参考。</p>
+1 -1
View File
@@ -89,7 +89,7 @@ function open(profile?: any) {
editImpact.value = null; editImpact.value = null;
editId.value = profile?.config.id || ''; editId.value = profile?.config.id || '';
sshConfirmation.value = undefined; sshConfirmation.value = undefined;
form.value = profile ? JSON.parse(JSON.stringify(profile.config)) : { name: '', outbound: { mode: 'k8s', kubeConfigAssetId: '', kubeContext: '', namespace: '', resource: '' } }; form.value = profile ? JSON.parse(JSON.stringify(profile.config)) : { name: '', outbound: { mode: 'k8s', kubeConfigAssetId: '', kubeContext: '', namespace: '', resource: '', localPort: undefined } };
Object.assign(form.value, { project: profile?.config.project || '', environment: profile?.config.environment ?? workspaceScope?.defaults().environment ?? '' }); Object.assign(form.value, { project: profile?.config.project || '', environment: profile?.config.environment ?? workspaceScope?.defaults().environment ?? '' });
issues.value = []; issues.value = [];
drawer.value = true; drawer.value = true;
+9 -1
View File
@@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { theme, themes } from '../theme'; import { theme, themes, resolvedTheme, toggleTheme } from '../theme';
import { Moon, Sunny } from '@element-plus/icons-vue';
import AppIcon from './AppIcon.vue'; import AppIcon from './AppIcon.vue';
</script> </script>
<template> <template>
<div class="theme-picker"> <div class="theme-picker">
<button type="button" class="theme-toggle" :aria-label="resolvedTheme === 'dark' ? '切换为浅色主题' : '切换为深色主题'" :title="resolvedTheme === 'dark' ? '切换为浅色主题' : '切换为深色主题'" @click="toggleTheme">
<Sunny v-if="resolvedTheme === 'dark'" aria-hidden="true" /><Moon v-else aria-hidden="true" />
</button>
<AppIcon name="palette" /> <AppIcon name="palette" />
<label for="theme-select">主题</label> <label for="theme-select">主题</label>
<select id="theme-select" v-model="theme" aria-label="主题"> <select id="theme-select" v-model="theme" aria-label="主题">
@@ -15,6 +19,10 @@ import AppIcon from './AppIcon.vue';
<style scoped> <style scoped>
.theme-picker { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--app-text-muted); font-size: 12px; } .theme-picker { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--app-text-muted); font-size: 12px; }
.theme-toggle { display: grid; place-items: center; flex: 0 0 30px; width: 30px; height: 30px; padding: 6px; border: 1px solid var(--app-border); border-radius: var(--app-radius); color: var(--app-text-regular); background: var(--app-chrome); cursor: pointer; }
.theme-toggle svg { width: 16px; height: 16px; }
.theme-toggle:hover { background: var(--app-hover); }
.theme-toggle:focus-visible { outline: 2px solid var(--app-ring); outline-offset: 1px; }
select { min-width: 0; max-width: 130px; min-height: 28px; padding: 2px 4px; border: 1px solid transparent; border-radius: var(--app-radius); color: var(--app-text-regular); background: var(--app-chrome); font: inherit; cursor: pointer; } select { min-width: 0; max-width: 130px; min-height: 28px; padding: 2px 4px; border: 1px solid transparent; border-radius: var(--app-radius); color: var(--app-text-regular); background: var(--app-chrome); font: inherit; cursor: pointer; }
select:hover { border-color: var(--app-border); } select:hover { border-color: var(--app-border); }
select:focus-visible { outline: 2px solid var(--app-ring); outline-offset: 1px; } select:focus-visible { outline: 2px solid var(--app-ring); outline-offset: 1px; }
+7 -1
View File
@@ -1,7 +1,13 @@
export function accessModeOptions(protocol: string): { value: string; label: string }[] {
const options = [{ value: 'readonly', label: '只读' }, { value: 'readwrite', label: '读写' }];
if (protocol === 'mysql') options.push({ value: 'unrestricted', label: '全放行' });
return options;
}
export function accessModeLabel(protocol: string, mode?: string): string { export function accessModeLabel(protocol: string, mode?: string): string {
if (protocol === 'ssh' || protocol === 'sftp') return 'SSH 会话'; if (protocol === 'ssh' || protocol === 'sftp') return 'SSH 会话';
if (mode === 'readonly') return '只读'; if (mode === 'readonly') return '只读';
if (protocol === 'mysql') return mode === 'unrestricted' ? '普通读写' : '安全读写'; if (protocol === 'mysql') return mode === 'unrestricted' ? '全放行' : '读写';
return '读写'; return '读写';
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export function createConnectionDraft(
targetHost: '', targetHost: '',
targetPort: ({tcp:80,mysql:3306,mongodb:27017,ssh:22,sftp:22,http:80,https:443,sqlserver:1433,redis:6379,rabbitmq:5672,pgsql:5432} as Record<string,number>)[type], targetPort: ({tcp:80,mysql:3306,mongodb:27017,ssh:22,sftp:22,http:80,https:443,sqlserver:1433,redis:6379,rabbitmq:5672,pgsql:5432} as Record<string,number>)[type],
targetUser: type === 'redis' ? 'default' : '', targetPassword: '', gatewayUser: '', gatewayPassword: '', targetUser: type === 'redis' ? 'default' : '', targetPassword: '', gatewayUser: '', gatewayPassword: '',
gatewayAccounts: ['tcp', 'redis', 'mongodb'].includes(type) ? [] : [{ id: accountID(), username: '', password: '', disabled: false, accessMode: ['ssh','sftp'].includes(type) ? undefined : 'readwrite', allowCIDRs: [], authorizedKeys: [] }], gatewayAccounts: ['tcp', 'redis', 'mongodb'].includes(type) ? [] : [{ id: accountID(), username: '', password: '', disabled: false, accessMode: ['mysql','ssh','sftp'].includes(type) ? undefined : 'readwrite', allowCIDRs: [], authorizedKeys: [] }],
accessMode: ['ssh','sftp','tcp'].includes(type) ? undefined : 'readwrite', accessMode: ['ssh','sftp','tcp'].includes(type) ? undefined : 'readwrite',
maxConnections: 100, targetTLS: !['redis','rabbitmq','http','tcp'].includes(type), maxConnections: 100, targetTLS: !['redis','rabbitmq','http','tcp'].includes(type),
database: '', targetCA: '', database: '', targetCA: '',
+1
View File
@@ -183,6 +183,7 @@ export function validateOutbound(draft: any, options: Options = {}): FieldIssue[
else if (o.kubeConfigAssetId && options.assets && !options.assets.some(row => row.config.id === o.kubeConfigAssetId && row.config.type === 'kubeconfig')) add('outbound.kubeConfigAssetId','Kubeconfig',proxyText('kubeconfigUnavailable')); else if (o.kubeConfigAssetId && options.assets && !options.assets.some(row => row.config.id === o.kubeConfigAssetId && row.config.type === 'kubeconfig')) add('outbound.kubeConfigAssetId','Kubeconfig',proxyText('kubeconfigUnavailable'));
if (!/^(pod|service|deployment|statefulset)\/[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(o.resource || '') || o.resource.includes('..') || o.resource.split('/')[1].length > 253) add('outbound.resource','Kubernetes 资源','请查询 Kubernetes 资源并从下拉框中选择'); if (!/^(pod|service|deployment|statefulset)\/[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(o.resource || '') || o.resource.includes('..') || o.resource.split('/')[1].length > 253) add('outbound.resource','Kubernetes 资源','请查询 Kubernetes 资源并从下拉框中选择');
if (o.namespace && (o.namespace.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(o.namespace))) add('outbound.namespace','命名空间','命名空间格式不正确'); if (o.namespace && (o.namespace.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(o.namespace))) add('outbound.namespace','命名空间','命名空间格式不正确');
if (o.localPort != null && o.localPort !== '' && Number(o.localPort) !== 0 && (!Number.isInteger(Number(o.localPort)) || Number(o.localPort) < 1024 || Number(o.localPort) > 65535)) add('outbound.localPort','本机监听端口','请输入 1024–65535 的高位端口,或留空自动分配');
} else { } else {
if (!o.host || /[\s/\\]/.test(o.host)) add('outbound.host','代理主机','请填写有效代理主机'); if (!o.host || /[\s/\\]/.test(o.host)) add('outbound.host','代理主机','请填写有效代理主机');
if (!port(o.port)) add('outbound.port','代理端口','端口必须为 1–65535'); if (!port(o.port)) add('outbound.port','代理端口','端口必须为 1–65535');
+8 -3
View File
@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
export const themes = [ export const themes = [
{ value: 'light', label: '浅色 · 经典' }, { value: 'light', label: '浅色 · 经典' },
@@ -12,12 +12,17 @@ function savedTheme() {
} }
export const theme = ref(savedTheme()); export const theme = ref(savedTheme());
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
const systemDark = ref(media.matches);
export const resolvedTheme = computed(() => theme.value === 'system' ? (systemDark.value ? 'dark' : 'light') : theme.value);
export function toggleTheme() {
theme.value = resolvedTheme.value === 'dark' ? 'light' : 'dark';
}
function apply() { function apply() {
const actual = theme.value === 'system' ? (media.matches ? 'dark' : 'light') : theme.value; const actual = resolvedTheme.value;
document.documentElement.dataset.theme = actual; document.documentElement.dataset.theme = actual;
document.documentElement.classList.toggle('dark', actual === 'dark'); document.documentElement.classList.toggle('dark', actual === 'dark');
document.documentElement.style.colorScheme = actual === 'dark' ? 'dark' : 'light'; document.documentElement.style.colorScheme = actual === 'dark' ? 'dark' : 'light';
} }
watch(theme, () => { apply(); try { localStorage.setItem('linkport-theme', theme.value); } catch {} }); watch(theme, () => { apply(); try { localStorage.setItem('linkport-theme', theme.value); } catch {} });
media.addEventListener('change', apply); media.addEventListener('change', event => { systemDark.value = event.matches; apply(); });
apply(); apply();
+13 -7
View File
@@ -6,12 +6,12 @@ import ts from 'typescript';
const module = { exports: {} }; const module = { exports: {} };
const source = readFileSync(new URL('../src/domain/accessMode.ts', import.meta.url), 'utf8'); const source = readFileSync(new URL('../src/domain/accessMode.ts', import.meta.url), 'utf8');
new Function('module', 'exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText)(module, module.exports); new Function('module', 'exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText)(module, module.exports);
const { accessModeLabel, gatewayAccessModeLabel } = module.exports; const { accessModeOptions, accessModeLabel, gatewayAccessModeLabel } = module.exports;
test('MySQL mode labels distinguish safe and ordinary writes; other protocols keep existing labels', () => { test('MySQL mode labels distinguish safe and ordinary writes; other protocols keep existing labels', () => {
assert.equal(accessModeLabel('mysql'), '安全读写'); assert.equal(accessModeLabel('mysql'), '读写');
assert.equal(accessModeLabel('mysql', 'readwrite'), '安全读写'); assert.equal(accessModeLabel('mysql', 'readwrite'), '读写');
assert.equal(accessModeLabel('mysql', 'unrestricted'), '普通读写'); assert.equal(accessModeLabel('mysql', 'unrestricted'), '全放行');
assert.equal(accessModeLabel('mysql', 'readonly'), '只读'); assert.equal(accessModeLabel('mysql', 'readonly'), '只读');
assert.equal(accessModeLabel('mongodb', 'readwrite'), '读写'); assert.equal(accessModeLabel('mongodb', 'readwrite'), '读写');
assert.equal(accessModeLabel('sftp'), 'SSH 会话'); assert.equal(accessModeLabel('sftp'), 'SSH 会话');
@@ -19,9 +19,15 @@ test('MySQL mode labels distinguish safe and ordinary writes; other protocols ke
test('gateway summary respects account modes and global readonly override', () => { test('gateway summary respects account modes and global readonly override', () => {
const config = { type: 'mysql', accessMode: 'readwrite', gatewayAccounts: [{ accessMode: 'unrestricted' }] }; const config = { type: 'mysql', accessMode: 'readwrite', gatewayAccounts: [{ accessMode: 'unrestricted' }] };
assert.equal(gatewayAccessModeLabel(config), '普通读写'); assert.equal(gatewayAccessModeLabel(config), '全放行');
assert.equal(gatewayAccessModeLabel({ ...config, accessMode: 'readonly' }), '只读'); assert.equal(gatewayAccessModeLabel({ ...config, accessMode: 'readonly' }), '只读');
assert.equal(gatewayAccessModeLabel({ ...config, gatewayAccounts: [...config.gatewayAccounts, {}] }), '按账号设置'); assert.equal(gatewayAccessModeLabel({ ...config, gatewayAccounts: [...config.gatewayAccounts, {}] }), '按账号设置');
assert.equal(gatewayAccessModeLabel({ ...config, gatewayAccounts: [...config.gatewayAccounts, { accessMode: 'readonly', disabled: true }] }), '普通读写'); assert.equal(gatewayAccessModeLabel({ ...config, gatewayAccounts: [...config.gatewayAccounts, { accessMode: 'readonly', disabled: true }] }), '全放行');
assert.equal(gatewayAccessModeLabel({ type: 'mysql', gatewayAccounts: [{}] }), '安全读写'); assert.equal(gatewayAccessModeLabel({ type: 'mysql', gatewayAccounts: [{}] }), '读写');
assert.equal(gatewayAccessModeLabel({ type: 'mysql', accessMode: 'unrestricted', gatewayAccounts: [{}] }), '全放行');
});
test('mode choices expose three MySQL policies without adding unrestricted to MongoDB', () => {
assert.deepEqual(accessModeOptions('mysql'), [{ value:'readonly', label:'只读' }, { value:'readwrite', label:'读写' }, { value:'unrestricted', label:'全放行' }]);
assert.equal(accessModeOptions('mongodb').length, 2);
}); });
+10
View File
@@ -76,18 +76,23 @@ test('新建 Kubeconfig 代理时上传配置、共享引用与版本信息', as
await drawer.getByRole('button', { name: '查询 Kubernetes 资源', exact: true }).click(); await drawer.getByRole('button', { name: '查询 Kubernetes 资源', exact: true }).click();
await drawer.getByRole('combobox', { name: '选择 Kubernetes 资源', exact: true }).click(); await drawer.getByRole('combobox', { name: '选择 Kubernetes 资源', exact: true }).click();
await page.getByRole('option', { name: 'service/mysql · 端口 3306', exact: true }).click(); await page.getByRole('option', { name: 'service/mysql · 端口 3306', exact: true }).click();
await expect(drawer.getByRole('radio', { name: '自动高位端口', exact: true })).toBeChecked();
await drawer.getByRole('radio', { name: '固定端口', exact: true }).locator('..').click();
await drawer.getByRole('spinbutton', { name: '固定本机端口', exact: true }).fill('55032');
await page.screenshot({ path: 'test-results/kubernetes-desktop.png' }); await page.screenshot({ path: 'test-results/kubernetes-desktop.png' });
const creating = page.waitForRequest(request => request.url().endsWith('/api/v1/outbounds') && request.method() === 'POST'); const creating = page.waitForRequest(request => request.url().endsWith('/api/v1/outbounds') && request.method() === 'POST');
const created = page.waitForResponse(response => response.url().endsWith('/api/v1/outbounds') && response.request().method() === 'POST'); const created = page.waitForResponse(response => response.url().endsWith('/api/v1/outbounds') && response.request().method() === 'POST');
await drawer.getByRole('button', { name: '保存代理' }).click(); await drawer.getByRole('button', { name: '保存代理' }).click();
const profileBody = (await creating).postDataJSON(); const profileBody = (await creating).postDataJSON();
expect(profileBody.outbound.kubeConfigAssetId).toBe(asset.config.id); expect(profileBody.outbound.kubeConfigAssetId).toBe(asset.config.id);
expect(profileBody.outbound.localPort).toBe(55032);
expect(profileBody.outbound.kubeConfigContent || '').toBe(''); expect(profileBody.outbound.kubeConfigContent || '').toBe('');
expect(JSON.stringify(profileBody)).not.toContain(initialToken); expect(JSON.stringify(profileBody)).not.toContain(initialToken);
const createdResponse = await created; const createdResponse = await created;
expect(createdResponse.ok()).toBeTruthy(); expect(createdResponse.ok()).toBeTruthy();
const profile = await createdResponse.json(); const profile = await createdResponse.json();
expect(profile.config.outbound.kubeConfigAssetId).toBe(asset.config.id); expect(profile.config.outbound.kubeConfigAssetId).toBe(asset.config.id);
expect(profile.config.outbound.localPort).toBe(55032);
expect(profile.config.outbound.kubeConfigContent || '').toBe(''); expect(profile.config.outbound.kubeConfigContent || '').toBe('');
expect(JSON.stringify(profile)).not.toContain(initialToken); expect(JSON.stringify(profile)).not.toContain(initialToken);
await expect(drawer).not.toBeVisible(); await expect(drawer).not.toBeVisible();
@@ -95,6 +100,7 @@ test('新建 Kubeconfig 代理时上传配置、共享引用与版本信息', as
await expect(proxyRow).toContainText('Kubeconfig'); await expect(proxyRow).toContainText('Kubeconfig');
await proxyRow.getByRole('button', { name: '编辑代理', exact: true }).click(); await proxyRow.getByRole('button', { name: '编辑代理', exact: true }).click();
await expect(page.getByRole('dialog', { name: '编辑代理', exact: true })).toBeVisible(); await expect(page.getByRole('dialog', { name: '编辑代理', exact: true })).toBeVisible();
await expect(drawer.getByRole('spinbutton', { name: '固定本机端口', exact: true })).toHaveValue('55032');
await expect(drawer.locator('.el-select').filter({ has: page.getByRole('combobox', { name: 'Kubeconfig 配置', exact: true }) })).toContainText('K8s 测试凭据'); await expect(drawer.locator('.el-select').filter({ has: page.getByRole('combobox', { name: 'Kubeconfig 配置', exact: true }) })).toContainText('K8s 测试凭据');
await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click(); await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click();
await expect(drawer).not.toBeVisible(); await expect(drawer).not.toBeVisible();
@@ -129,6 +135,10 @@ test('新建 Kubeconfig 代理时上传配置、共享引用与版本信息', as
await expect.poll(async () => (await drawer.boundingBox())?.x).toBeGreaterThanOrEqual(0); await expect.poll(async () => (await drawer.boundingBox())?.x).toBeGreaterThanOrEqual(0);
await expect.poll(async () => (await drawer.boundingBox())?.width).toBeLessThanOrEqual(390); await expect.poll(async () => (await drawer.boundingBox())?.width).toBeLessThanOrEqual(390);
await page.screenshot({ path: 'test-results/kubernetes-mobile.png' }); await page.screenshot({ path: 'test-results/kubernetes-mobile.png' });
const localPortField = drawer.locator('[data-field="outbound.localPort"]');
await localPortField.scrollIntoViewIfNeeded();
await expect(drawer.getByRole('spinbutton', { name: '固定本机端口', exact: true })).toHaveValue('55032');
await page.screenshot({ path: 'test-results/kubernetes-local-port-mobile.png' });
expect(await drawer.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true); expect(await drawer.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click(); await drawer.locator('.workspace-page__footer').getByRole('button', { name: '取消', exact: true }).click();
await expect(drawer).not.toBeVisible(); await expect(drawer).not.toBeVisible();
+28 -9
View File
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
test('MySQL 账号可独立选择三种访问模式,普通读写保存后保持且全局只读有覆盖提示', async ({ page }) => { test('MySQL 网关和账号提供只读、读写、全放行,保存后保持且网关只读优先', async ({ page }) => {
await page.goto('/'); await page.goto('/');
const session = await (await page.request.get('/api/v1/session')).json(); const session = await (await page.request.get('/api/v1/session')).json();
const headers = { 'X-CSRF-Token': session.csrfToken }; const headers = { 'X-CSRF-Token': session.csrfToken };
@@ -23,13 +23,12 @@ test('MySQL 账号可独立选择三种访问模式,普通读写保存后保
await dialog.getByRole('tab', { name: '账号', exact: true }).click(); await dialog.getByRole('tab', { name: '账号', exact: true }).click();
await editor.getByRole('tab', { name: '权限', exact: true }).click(); await editor.getByRole('tab', { name: '权限', exact: true }).click();
const selector = editor.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '账号访问模式', exact: true }) }); const selector = editor.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '账号访问模式', exact: true }) });
await expect(selector).toContainText('安全读写(默认)'); await expect(selector).toContainText('继承网关(读写)');
await selector.click(); await selector.click();
for (const label of ['只读', '安全读写(默认)', '普通读写']) { for (const label of ['只读', '读写', '全放行']) {
await expect(page.getByRole('option', { name: label, exact: true })).toBeVisible(); await expect(page.getByRole('option', { name: label, exact: true })).toBeVisible();
} }
await page.getByRole('option', { name: '普通读写', exact: true }).click(); await page.getByRole('option', { name: '全放行', exact: true }).click();
await expect(editor).toContainText('SQL 权限由上游数据库控制');
await page.setViewportSize({ width: 320, height: 568 }); await page.setViewportSize({ width: 320, height: 568 });
await expect(selector).toBeInViewport(); await expect(selector).toBeInViewport();
await expect.poll(() => dialog.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true); await expect.poll(() => dialog.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
@@ -44,16 +43,36 @@ test('MySQL 账号可独立选择三种访问模式,普通读写保存后保
await row.getByRole('button', { name: '配置', exact: true }).click(); await row.getByRole('button', { name: '配置', exact: true }).click();
await dialog.getByRole('tab', { name: '账号', exact: true }).click(); await dialog.getByRole('tab', { name: '账号', exact: true }).click();
await editor.getByRole('tab', { name: '权限', exact: true }).click(); await editor.getByRole('tab', { name: '权限', exact: true }).click();
await expect(selector).toContainText('普通读写'); await expect(selector).toContainText('全放行');
await dialog.getByRole('tab', { name: '网关与访问', exact: true }).click(); await dialog.getByRole('tab', { name: '网关与访问', exact: true }).click();
await dialog.getByRole('switch', { name: '只读模式', exact: true }).click(); const gatewaySelector = dialog.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '网关访问模式', exact: true }) });
await gatewaySelector.click();
for (const label of ['只读', '读写', '全放行']) await expect(page.getByRole('option', { name: label, exact: true })).toBeVisible();
await page.getByRole('option', { name: '只读', exact: true }).click();
await dialog.getByRole('tab', { name: '账号', exact: true }).click(); await dialog.getByRole('tab', { name: '账号', exact: true }).click();
await editor.getByRole('tab', { name: '权限', exact: true }).click(); await editor.getByRole('tab', { name: '权限', exact: true }).click();
await expect(editor).toContainText('网关已开启只读,所有账号均按只读模式执行'); await expect(editor).toContainText('所有账号仅允许读取请求');
await expect(selector).toContainText('普通读写'); await expect(selector).toContainText('全放行');
await dialog.getByRole('button', { name: '保存配置', exact: true }).click(); await dialog.getByRole('button', { name: '保存配置', exact: true }).click();
await expect(dialog).not.toBeVisible(); await expect(dialog).not.toBeVisible();
await expect(row.locator('.mode-tag')).toHaveText('只读'); await expect(row.locator('.mode-tag')).toHaveText('只读');
await page.setViewportSize({ width: 1440, height: 1000 });
for (const [label, value] of [['全放行', 'unrestricted'], ['读写', 'readwrite']]) {
await row.getByRole('button', { name: '配置', exact: true }).click();
await dialog.getByRole('tab', { name: '网关与访问', exact: true }).click();
await gatewaySelector.click();
await page.getByRole('option', { name: label, exact: true }).click();
await dialog.getByRole('button', { name: '保存配置', exact: true }).click();
await expect(dialog).not.toBeVisible();
const changed = await (await page.request.get(`/api/v1/proxies/${id}`)).json();
expect(changed.config.accessMode).toBe(value);
expect(changed.config.gatewayAccounts[0].accessMode).toBe('unrestricted');
await row.getByRole('button', { name: '配置', exact: true }).click();
await dialog.getByRole('tab', { name: '网关与访问', exact: true }).click();
await expect(gatewaySelector).toContainText(label);
await dialog.getByRole('button', { name: '取消', exact: true }).last().click();
await expect(dialog).not.toBeVisible();
}
} finally { } finally {
await page.request.delete(`/api/v1/proxies/${id}`, { headers }).catch(() => {}); await page.request.delete(`/api/v1/proxies/${id}`, { headers }).catch(() => {});
} }
+25
View File
@@ -0,0 +1,25 @@
import { test, expect } from '@playwright/test';
test('一键主题切换保留偏好,跟随系统时按实际主题切换,手机可用', async ({ page }, testInfo) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
await page.getByLabel('主题', { exact: true }).selectOption('light');
await page.getByRole('button', { name: '切换为深色主题', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await page.reload();
await expect(page.getByLabel('主题', { exact: true })).toHaveValue('dark');
await page.getByRole('button', { name: '切换为浅色主题', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.getByLabel('主题', { exact: true }).selectOption('system');
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page.getByRole('button', { name: '切换为浅色主题', exact: true })).toBeVisible();
await page.getByRole('button', { name: '切换为浅色主题', exact: true }).click();
await expect(page.getByLabel('主题', { exact: true })).toHaveValue('light');
for (const width of [1440, 390, 320]) {
await page.setViewportSize({ width, height: 844 });
const button = page.getByRole('button', { name: '切换为深色主题', exact: true });
await expect(button).toBeInViewport();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: testInfo.outputPath(`theme-toggle-${width}.png`) });
}
});