升级 Go 工具链并完善 MySQL 策略、Kubernetes 端口和主题切换
This commit is contained in:
+2
-2
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
@@ -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
@@ -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 连通性验收。
|
||||||
|
|||||||
@@ -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 透传模式。
|
||||||
|
|
||||||
## 常用协议命令
|
## 常用协议命令
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 切换、登录主题持久化和系统主题跟随。用例包含截图输出,但本次没有执行成功或生成截图。
|
||||||
|
|||||||
@@ -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 嵌入资源。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.')
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 '读写';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: '',
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`) });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user