From 408f346ab157f69be119542db27cfeaf08c770d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E5=BF=97=E5=A8=81?= Date: Tue, 22 Sep 2026 14:00:42 +0800 Subject: [PATCH] feat: add WebDAV sync backups and unauthenticated targets Replace active S3 sync with encrypted WebDAV synchronization and independent backups. Support explicit empty passwords and unauthenticated MongoDB, Redis, HTTP and HTTPS targets. Include migration, design and validation documentation. Validation: build and go vet passed; Playwright 113 passed with 4 existing failures. Full Go run reproduced the existing cluster discovery test failure. Preserve unrelated SSH fingerprint changes outside this commit. --- README.md | 2 +- docs/change-rationale.md | 20 +++ docs/configuration-backup.md | 2 +- docs/gateway-operations.md | 4 +- docs/live-target-switch.md | 2 +- docs/modules.md | 13 +- docs/mysql-result-audit.md | 2 +- docs/project-environments.md | 2 +- docs/responsive-ui.md | 2 +- docs/s3-sync.md | 42 +---- docs/single-file-storage.md | 2 +- docs/sqlite.md | 2 +- docs/ssh-command-audit.md | 2 +- docs/target-accounts.md | 38 +++- docs/target-connection-test.md | 3 +- docs/testing.md | 18 ++ docs/webdav-sync.md | 80 +++++++++ docs/workspace-ui.md | 2 +- internal/api/s3_sync.go | 106 ----------- internal/api/s3_sync_test.go | 89 ---------- internal/api/server.go | 4 +- internal/api/target_auth_test.go | 73 ++++++++ internal/api/target_draft.go | 3 + internal/api/tokens.go | 2 +- internal/api/webdav_sync.go | 103 +++++++++++ internal/api/webdav_sync_test.go | 119 +++++++++++++ internal/backup/webdav.go | 143 +++++++++++++++ internal/backup/webdav_test.go | 130 ++++++++++++++ internal/model/target_account.go | 12 ++ internal/model/target_auth.go | 17 ++ internal/model/target_auth_test.go | 75 ++++++++ internal/model/validation.go | 3 +- internal/protocol/httpproxy/http.go | 9 +- internal/protocol/httpproxy/no_auth_test.go | 71 ++++++++ internal/protocol/mongoproxy/gateway.go | 4 +- internal/protocol/mongoproxy/no_auth_test.go | 120 +++++++++++++ internal/store/portable.go | 1 + internal/store/s3_sync.go | 166 ----------------- internal/store/store.go | 2 +- internal/store/sync_bundle.go | 6 +- internal/store/sync_bundle_test.go | 21 +-- internal/store/webdav_sync.go | 161 +++++++++++++++++ internal/syncjob/engine.go | 49 +++-- internal/syncjob/engine_test.go | 36 ++-- internal/syncjob/scope_catalog_test.go | 2 +- internal/syncjob/webdav_backup_test.go | 69 +++++++ web/src/App.vue | 8 +- web/src/components/ConnectionURLImport.vue | 2 +- web/src/components/MongoConnectionImport.vue | 2 +- web/src/components/S3SyncManager.vue | 85 --------- web/src/components/TargetAccounts.vue | 14 +- web/src/components/TargetDraftTest.vue | 4 +- web/src/components/WebDAVSyncManager.vue | 86 +++++++++ .../{useS3Sync.ts => useWebDAVSync.ts} | 41 ++--- web/src/domain/formValidation.ts | 5 +- web/src/domain/targetAuth.ts | 12 ++ web/src/domain/targetDraft.ts | 5 + web/tests/menu-search.spec.ts | 4 +- web/tests/s3-sync.spec.ts | 64 ------- web/tests/target-auth.spec.ts | 168 ++++++++++++++++++ web/tests/webdav-sync.spec.ts | 140 +++++++++++++++ 61 files changed, 1818 insertions(+), 656 deletions(-) create mode 100644 docs/webdav-sync.md delete mode 100644 internal/api/s3_sync.go delete mode 100644 internal/api/s3_sync_test.go create mode 100644 internal/api/target_auth_test.go create mode 100644 internal/api/webdav_sync.go create mode 100644 internal/api/webdav_sync_test.go create mode 100644 internal/backup/webdav.go create mode 100644 internal/backup/webdav_test.go create mode 100644 internal/model/target_auth.go create mode 100644 internal/model/target_auth_test.go create mode 100644 internal/protocol/httpproxy/no_auth_test.go create mode 100644 internal/protocol/mongoproxy/no_auth_test.go delete mode 100644 internal/store/s3_sync.go create mode 100644 internal/store/webdav_sync.go create mode 100644 internal/syncjob/webdav_backup_test.go delete mode 100644 web/src/components/S3SyncManager.vue create mode 100644 web/src/components/WebDAVSyncManager.vue rename web/src/composables/{useS3Sync.ts => useWebDAVSync.ts} (61%) create mode 100644 web/src/domain/targetAuth.ts delete mode 100644 web/tests/s3-sync.spec.ts create mode 100644 web/tests/target-auth.spec.ts create mode 100644 web/tests/webdav-sync.spec.ts diff --git a/README.md b/README.md index 38f4b38..4d5b03c 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ 「配置管理」通过「导出配置」入口导出全部配置,使用口令加密,包含资产管理的全部文件内容、文件名、引用关系及恢复所需凭据。导入时需要相同口令。配置模板不包含资产文件内容及凭据,适合分享配置结构。导入预览按与实际导入相同的合并规则校验现有引用,展示新增、覆盖、未变更资源及脱敏字段差异,不写入数据库。确认前须手动停止所有网关;预览后配置发生变化会拒绝提交并要求重新预览。详见 [配置备份与资产文件](docs/configuration-backup.md)。 -配置中心支持 S3 多端同步,在设备间加密同步目标、代理、资产、凭据和网关配置,支持手动及定时同步、删除传播与冲突选择,使用 ETag 条件写入防止并发覆盖。设置方式见 [S3 多端同步](docs/s3-sync.md)。 +配置中心支持 WebDAV 多端同步,在设备间加密同步目标、代理、资产、凭据和网关配置,支持手动及定时同步、独立加密备份、删除传播与冲突选择,使用 ETag 条件写入防止并发覆盖。设置方式见 [WebDAV 多端同步](docs/webdav-sync.md)。 运行中的网关支持通过「切换目标」或「拖拽连线」调整目标服务及共用账号。监听保持运行,新连接使用新目标,已有连接保留原目标。详见 [运行中切换目标](docs/live-target-switch.md)。 diff --git a/docs/change-rationale.md b/docs/change-rationale.md index ded9921..9417cc2 100644 --- a/docs/change-rationale.md +++ b/docs/change-rationale.md @@ -1,5 +1,25 @@ # 变更原因与设计记录 +## WebDAV 替代 S3 同步并提供独立备份(2026-09-22) + +原因:用户要求将 S3 同步改为 WebDAV 同步备份,不再保留双入口造成配置歧义,同时需要不影响共享同步状态的归档能力。 + +结果:传输改为 WebDAV 基础目录、相对前缀和 Basic/匿名认证,MKCOL 创建子目录,强 ETag 与条件 PUT 保留双端冲突保护。保留手动/定时同步、删除传播和停止网关后应用远端的约束;新增独立加密快照上传,不读取或覆盖共享文件、不改变同步基线或解决冲突。管理页、状态、事件及会话保护 API 统一替换为 WebDAV;密码和口令不回传,拒绝跳转和服务商正文泄漏,改变地址或用户名不继承旧登录密码。 + +范围与兼容:涉及 backup/store/syncjob/api 和独立 Vue 组件/composable。新增独立加密设置、状态表;旧 S3 表、文件保留但停止使用,旧 API 返回 404,新自动任务默认关闭,需要重新配置。沿用设置与基线、配置替换与基线的短事务原子性,数据库操作限单例或已定位资源,不把网络请求放入事务。升级须停服并备份完整数据目录,本轮未部署或迁移真实数据。备份是配置快照,不含审计/个人登录等设备数据;无远端列表、自动轮转和在线恢复选择,下载后通过配置管理导入。手动备份会更新任务时间并顺延下次自动同步。 + +验证:Go HTTP fixture 覆盖认证、目录编码、条件写入、强 ETag、并发冲突、双端同步、独立可恢复快照以及重定向/错误脱敏;浏览器包含隔离程序连接本机 Node WebDAV fixture 的备份与同步链路。未测试真实外部 WebDAV 服务,实际完整结果见 [测试记录](testing.md),操作与迁移见 [WebDAV 同步备份](webdav-sync.md)。 + +## 目标空密码与无认证连接(2026-09-22) + +原因:用户指出部分服务无需密码即可登录。现有账号密码本已允许为空,但前后端统一要求目标用户名,Mongo 上游始终发起 SCRAM,HTTP 始终添加 Basic 头;编辑空密码还会按既有规则保留旧密码,导致无法明确表达无认证与主动清除密码。 + +结果:具名账号继续允许空密码,由目标服务器决定是否接受;编辑器提供“使用空密码(清除已保存密码)”,保存和草稿测试统一发送显式空值。MongoDB、Redis、HTTP(S) 的目标账号新增 `authMethod: none`,保留账号 ID 与网关映射,不发送上游用户名/密码,保存时清除原凭据及私钥/证书引用。Mongo 跳过上游 SASL,HTTP 去除客户端 Authorization 后仅在有目标用户名时设置 Basic;Redis 沿用既有无凭据时不发 AUTH 的实现。URL 导入带凭据时切回密码模式并撤销清空标记,避免已填密码被忽略。 + +安全与兼容:网关登录、权限、源地址限制和账号到期检查不放宽;上游认证失败不会自动降级。SSH/SFTP 仍可用私钥;其他具名协议不新增无认证选项。旧配置空密码保留语义不变,使用现有账号 JSON,无数据库迁移;旧版不支持 `none` 模式,回退前需重新配置。保留工作区已有 SSH 指纹跳过改动,不部署、不提交、不修改真实目标数据。 + +验证:模型/API 专项覆盖协议限制、空密码、显式清除、草稿不落库及不放宽网关认证;Mongo/HTTP 使用本机协议 fixture 验证不发送上游认证、仍拒绝错误网关密码及目标拒绝时返回失败,Redis 无认证覆盖沿用现有 fixture。浏览器使用新构建程序、隔离数据库及模拟远程结果,不代表真实服务验证。实际构建和回归结果见 [测试记录](testing.md)。 + ## 合并 origin/main 943ea75(2026-09-22) 原因:将远端最新提交合入本地 main,同时保留已提交的 TCP 映射、MongoDB 非 admin 认证库和单 SQLite 存储等功能。 diff --git a/docs/configuration-backup.md b/docs/configuration-backup.md index 5ab75ab..5923ddf 100644 --- a/docs/configuration-backup.md +++ b/docs/configuration-backup.md @@ -15,7 +15,7 @@ 配置备份保留全部资产文件和凭据。配置模板独立用于复用配置结构,保存时仍然脱敏,不能替代完整备份;如果在模板编辑器中手动补入凭据,导出的当前 JSON 会包含这些手动填写的值。 -配置备份不包含审计日志、个人登录凭据、API Token、历史记录或设备本地的主密钥。需要设备间持续同步时使用独立的「多端同步」菜单,详见 [S3 多端同步](s3-sync.md)。 +配置备份不包含审计日志、个人登录凭据、API Token、历史记录或设备本地的主密钥。需要设备间持续同步时使用独立的「多端同步」菜单,详见 [WebDAV 多端同步](webdav-sync.md)。 API 对应关系: diff --git a/docs/gateway-operations.md b/docs/gateway-operations.md index 90600ad..196b088 100644 --- a/docs/gateway-operations.md +++ b/docs/gateway-operations.md @@ -77,7 +77,7 @@ TCP 连接和协议认证分别计数。手动健康检查最多尝试 3 次, ## 配置备份和历史 -从侧边栏「配置管理」进入配置导出、导入、配置模板和历史恢复页面。「导出配置」填写口令后下载加密文件,包含资产管理中的全部原始文件、上传文件名、资源引用和凭据,详见 [配置备份](configuration-backup.md)。S3 设备间配置同步使用独立的「多端同步」入口,详见 [S3 多端同步](s3-sync.md)。 +从侧边栏「配置管理」进入配置导出、导入、配置模板和历史恢复页面。「导出配置」填写口令后下载加密文件,包含资产管理中的全部原始文件、上传文件名、资源引用和凭据,详见 [配置备份](configuration-backup.md)。WebDAV 设备间配置同步使用独立的「多端同步」入口,详见 [WebDAV 多端同步](webdav-sync.md)。 恢复历史配置前,页面显示当前配置与历史版本的差异。密码、私钥、口令和 Kubernetes 凭据仅标记变更,不返回原值;账号按稳定 ID 对比。 切换历史资源会立即清空旧列表,并忽略先前资源迟到的列表和差异响应。差异弹窗与恢复确认均显示资源名称、ID 或版本;恢复期间防止重复提交,操作始终绑定确认时的资源与版本。 @@ -99,7 +99,7 @@ TCP 连接和协议认证分别计数。手动健康检查最多尝试 3 次, 不能代替完整数据目录备份。 高可用采用独立实例加外部负载均衡,见 [高可用部署](high-availability.md)。 -设备间配置可通过 S3 多端同步手动或定时同步;不提供运行中会话迁移。 +设备间配置可通过 WebDAV 多端同步手动或定时同步;不提供运行中会话迁移。 ## 拖拽图中的目标多账号 diff --git a/docs/live-target-switch.md b/docs/live-target-switch.md index b86d2c6..219934e 100644 --- a/docs/live-target-switch.md +++ b/docs/live-target-switch.md @@ -12,7 +12,7 @@ 服务器先校验目标协议、账号映射及 SSH 主机指纹,再保存并发布运行配置。保存失败时继续使用原目标。切换不等于连通性验证,可在目标服务页面先测试连接。SSH/SFTP 目标仍使用已确认的主机指纹校验策略。 -只允许在线更改目标及账号映射;监听端口、网关认证和访问策略仍通过停止后的完整配置编辑。S3 整体配置同步涉及新增、删除和监听配置,仍在本机全部网关停止后应用。 +只允许在线更改目标及账号映射;监听端口、网关认证和访问策略仍通过停止后的完整配置编辑。WebDAV 整体配置同步涉及新增、删除和监听配置,仍在本机全部网关停止后应用。 API:`POST /api/v1/proxies/{id}/target`,请求包含 `targetId` 和 `targetAccountId`;可选 `gatewayAccountId` 指定单个账号,省略则统一设置全部账号。适用于已停止和运行中的网关;个人会话写请求需要 CSRF,API Token 需要写入作用域。不接受网关密码或监听字段。成功返回脱敏配置及当前运行状态,绑定会持久保存。 diff --git a/docs/modules.md b/docs/modules.md index bb6e770..afd1489 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -57,7 +57,7 @@ flowchart LR | --- | --- | | [proxy.go](../internal/model/proxy.go) | 网关及运行时配置、网关账号和出口等公共结构、默认值、配置脱敏;定义 Handler、DialFunc、TestResult 等接口与结果类型 | | [target.go](../internal/model/target.go) | 目标结构,将目标配置组合进运行时网关;GatewayOnly 去掉网关记录里的上游材料 | -| [target_account.go](../internal/model/target_account.go) | 目标多账号、密码或 SSH 私钥认证、账号选择和凭据更新规则 | +| [target_account.go](../internal/model/target_account.go)、[target_auth.go](../internal/model/target_auth.go) | 目标多账号、密码/空密码/SSH 私钥认证、支持协议的无认证模式、账号选择和凭据更新规则 | | [gateway_account.go](../internal/model/gateway_account.go) | 网关多账号校验、稳定账号 ID 和编辑时保留已存秘密的规则 | | [credential_lifecycle.go](../internal/model/credential_lifecycle.go) | 凭据到期与提醒、目标账号引用校验、账号映射和运行时凭据有效性检查 | | [outbound_profile.go](../internal/model/outbound_profile.go)、[kubernetes.go](../internal/model/kubernetes.go) | 可共享的出口配置、出口合并校验,以及 Kubernetes 资源、命名空间和配置字段校验 | @@ -85,6 +85,7 @@ flowchart LR | [readiness.go](../internal/api/readiness.go) | readyz | 根据已绑定网关及监听器状态判断就绪;目标协议可用性由健康检查判断 | | [alerts.go](../internal/api/alerts.go) | alerts | 告警查询、检查、确认和通知设置 | | [configuration.go](../internal/api/configuration.go) | configuration | 加密配置导出、合并导入预览、提交、历史差异和恢复;导出包含全部资产文件与凭据;导入与恢复要求网关已停止 | +| [webdav_sync.go](../internal/api/webdav_sync.go) | configuration/webdav | WebDAV 脱敏设置、状态、手动同步及独立备份;禁止 API Token | | [templates.go](../internal/api/templates.go) | templates | 脱敏配置模板的保存、列表、读取与删除 | 新增接口应放在对应领域文件中,`server.go` 保留路由和公共控制。页面隐藏按钮只是交互控制,真正的权限检查在后端执行。 @@ -145,6 +146,7 @@ flowchart LR | [tokens.go](../internal/store/tokens.go) | API Token 哈希、作用域、过期及撤销;当前文件也包含模板 CRUD,模板记录加密保存,保存及详情读取进行脱敏 | | [templates.go](../internal/store/templates.go) | 模板结构、版本和资源 ID 校验;模板允许缺少凭据,实际导入另做完整校验 | | [backup.go](../internal/store/backup.go) | 配置包、模板脱敏、口令加密备份、导入合并、引用校验和事务恢复;加密导出包含全部资产原文、文件名、引用及恢复所需凭据 | +| [webdav_sync.go](../internal/store/webdav_sync.go)、[sync_bundle.go](../internal/store/sync_bundle.go) | WebDAV 加密设置、租约和同步基线;规范化快照、加密、冲突及完整替换 | | [assets.go](../internal/store/assets.go)、[api/assets.go](../internal/api/assets.go) | 多类型文件资产加密存储、上传更新、目标/账号/代理引用保护和脱敏接口;更新后引用配置使用最新内容,资产纳入配置备份和历史 | | [import_preview.go](../internal/store/import_preview.go) | 共用导入合并规则,生成新增/覆盖/未变更列表、脱敏差异与配置修订标识;识别预览后的配置变更 | | [config_history.go](../internal/store/config_history.go)、[config_diff.go](../internal/store/config_diff.go) | 配置历史版本及字段对比;敏感值仅报告是否变化,账号按稳定 ID 比较 | @@ -159,6 +161,10 @@ flowchart LR [engine.go](../internal/alerts/engine.go) 读取运行指标、健康结果、账号到期时间和证书到期信息,周期检查并维护告警状态。告警默认站内展示;管理员启用 Webhook 后,才会发送通知,可配置 HMAC-SHA256 签名并有限重试。HTTP 接口放在 api/alerts.go,告警记录放在 store/alerts.go。 +### 7.3 WebDAV 同步与传输 + +[internal/syncjob/engine.go](../internal/syncjob/engine.go) 协调手动/定时多端同步、冲突选择和独立加密备份;[internal/backup/webdav.go](../internal/backup/webdav.go) 负责 Basic/匿名认证、目录创建、GET 和条件 PUT。旧 S3 帮助代码只为历史兼容保留,当前页面、API 和调度不再使用。操作及升级说明见 [WebDAV 同步备份](webdav-sync.md)。 + ## 8. 前端页面与公共状态:web/src | 文件 / 组件 | 负责什么 | 用户在哪里使用 | @@ -167,7 +173,7 @@ flowchart LR | [App.vue](../web/src/App.vue) | 导航、登录、共享目标/网关/出口数据、概览、目标和网关主视图、编辑抽屉、批量操作及审计页面协调 | 管理后台主框架 | | [SidebarNavigation.vue](../web/src/components/SidebarNavigation.vue) | 按名称与关键词搜索有权访问的菜单,支持键盘选择和手机搜索面板 | 左侧菜单 | | [Connections.vue](../web/src/Connections.vue) | 展示目标与网关关系;连线时展开目标账号,拖拽或点选完成多对一映射;支持启停、维护目标和断开关联 | 拖拽连线 | -| [TargetAccounts.vue](../web/src/components/TargetAccounts.vue) | 编辑目标多个账号及密码/私钥认证 | 目标配置 | +| [TargetAccounts.vue](../web/src/components/TargetAccounts.vue) | 编辑目标多个账号、空密码/私钥及支持协议的无认证模式 | 目标配置 | | [GatewayAccounts.vue](../web/src/components/GatewayAccounts.vue) | 在独立账号页签编辑网关账号、目标账号映射、禁用、来源及账号策略 | 网关配置 | | [SourceAllowlist.vue](../web/src/components/SourceAllowlist.vue) | 单行编辑 IP/CIDR 列表,区分网关与账号作用范围,支持逗号分隔多项 | 网关配置与账号页签 | | [AssetsManager.vue](../web/src/components/AssetsManager.vue)、[AssetUpload.vue](../web/src/components/AssetUpload.vue)、[AssetSelect.vue](../web/src/components/AssetSelect.vue) | 多类型文件资产上传替换、引用展示及删除保护;按类型选择资产,目标/账号/代理按 ID 复用文件;KubeconfigUpload 为兼容包装 | 资产管理与配置编辑 | @@ -179,12 +185,13 @@ flowchart LR | [AlertsPanel.vue](../web/src/components/AlertsPanel.vue) | 告警列表、检查、确认和 Webhook 设置 | 监控运维 → 告警中心 | | [ConfigurationManager.vue](../web/src/components/ConfigurationManager.vue) | 加密配置导出(含全部资产文件)、导入预览和重新预览、确认导入、历史差异与恢复 | 配置管理 → 配置备份/历史 | | [TemplateManager.vue](../web/src/components/TemplateManager.vue) | 保存当前配置为模板、文件导入、查看编辑、导出、预览应用和删除 | 配置管理 → 配置模板 | -| [S3SyncManager.vue](../web/src/components/S3SyncManager.vue) | S3 连接设置、手动及自动多端同步、同步状态和冲突选择 | 多端同步 | +| [WebDAVSyncManager.vue](../web/src/components/WebDAVSyncManager.vue) | WebDAV 连接设置、手动及自动多端同步、独立加密备份、状态和冲突选择 | 多端同步 | | [TokenManager.vue](../web/src/components/TokenManager.vue) | 选择作用域和有效期创建 Token、一次性显示密钥、查看与撤销 | 监控运维 → API Token | | [VersionInfo.vue](../web/src/components/VersionInfo.vue) | 查询和展示构建版本信息 | 管理界面版本信息 | | [composables/useApi.ts](../web/src/composables/useApi.ts) | 封装 JSON 请求、会话 CSRF 更新、错误结果;仅在明确的 CSRF 拒绝后重试一次写请求 | 所有管理请求共用 | | [composables/useAudit.ts](../web/src/composables/useAudit.ts) | 命令和事件筛选、翻页、导出,以及避免旧请求结果覆盖新筛选 | 命令审计、连接事件 | | [composables/useConfigurationHistory.ts](../web/src/composables/useConfigurationHistory.ts) | 管理历史资源选择、列表和差异请求时序,忽略过期响应,绑定恢复对象并防止重复提交 | 配置历史与恢复 | +| [composables/useWebDAVSync.ts](../web/src/composables/useWebDAVSync.ts) | WebDAV 设置草稿、秘密保留/清除、同步备份请求及状态轮询 | 多端同步 | | [composables/useMonitoring.ts](../web/src/composables/useMonitoring.ts) | 每 5 秒读取监控、请求去重、页面可见性与生命周期、刷新失败提示,以及检查间隔草稿保护 | 监控运维 | | [domain/outbound.ts](../web/src/domain/outbound.ts) | 统一出口类型名称及摘要文案 | 出口列表和引用展示 | | [theme.ts](../web/src/theme.ts)、[style.css](../web/src/style.css) | 主题选择、浏览器偏好保存、跟随系统和全局响应式样式 | 全局外观 | diff --git a/docs/mysql-result-audit.md b/docs/mysql-result-audit.md index 327bdcb..db69c93 100644 --- a/docs/mysql-result-audit.md +++ b/docs/mysql-result-audit.md @@ -19,6 +19,6 @@ MySQL 网关为 `COM_QUERY` 普通查询和 `COM_STMT_EXECUTE` 预编译执行 “下载完整结果”导出 NDJSON,每行是一个独立 JSON 事件:`begin`、`column`、`metadata`、`row`、`end`、`ok`、`error` 或 `raw`。事件的 `index` 对应结果序号,行的 `values` 按列顺序排列。下载包含开始下载时已保存的内容;运行中的查询可稍后再次下载。CSV 命令导出仍只导出命令记录,结果单独下载。 -结果和状态加密保存在原命令所在的 UTC 日期审计库中,随命令的 7 天保留期清理;要求个人会话或 audit API Token。配置 S3 备份不包含审计结果,完整审计备份需停服后复制整个数据目录。 +结果和状态加密保存在原命令所在的 UTC 日期审计库中,随命令的 7 天保留期清理;要求个人会话或 audit API Token。配置 WebDAV 备份不包含审计结果,完整审计备份需停服后复制整个数据目录。 旧记录无法补录查询结果。部署新版后重新建立 MySQL 连接,后续查询才会记录;仅预编译、发送长参数、释放语句、被拦截的请求和其他管理命令不记录查询返回行。 diff --git a/docs/project-environments.md b/docs/project-environments.md index 1c6fe08..52798c5 100644 --- a/docs/project-environments.md +++ b/docs/project-environments.md @@ -22,7 +22,7 @@ 审计在分页前按资源归属过滤,CSV 导出使用相同筛选。历史审计按资源当前归属显示;已删除资源使用最后一份配置历史中的归属。没有资源关联的系统事件归入未分配范围。项目和环境筛选用于组织个人工作台中的数据。 -加密备份和多端同步包含项目、环境字段及独立维护的选项列表,完整备份仍导出所有项目与环境。导入备份合并选项;旧备份不清空已有选项。S3 下载按完整配置替换选项。个人登录凭据、访问令牌、同步设置及监控策略属于设备级设置;筛选不改变这些设置。 +加密备份和多端同步包含项目、环境字段及独立维护的选项列表,完整备份仍导出所有项目与环境。导入备份合并选项;旧备份不清空已有选项。WebDAV 下载按完整配置替换选项。个人登录凭据、访问令牌、同步设置及监控策略属于设备级设置;筛选不改变这些设置。 部署前应停止本机服务并备份完整 `data` 目录;开发验证使用独立数据库,不直接迁移正在使用的数据。 diff --git a/docs/responsive-ui.md b/docs/responsive-ui.md index de50bd1..9b0ec30 100644 --- a/docs/responsive-ui.md +++ b/docs/responsive-ui.md @@ -19,7 +19,7 @@ - 宽屏内容随工作区伸缩;筛选、操作栏和协议选项按可用空间换行。 - 720px 及以下使用图标导航、单列表单和网关卡片,保留配置管理、多端同步、监控运维、密码解密与退出登录入口。 - 低高度窗口的侧边导航独立滚动。弹窗和抽屉限制为可视窗口高度,内容滚动时仍可访问底部操作。 -- 左侧菜单支持按名称和常用关键词搜索(如 S3、备份);桌面直接筛选,手机点击搜索图标打开菜单选择面板。搜索结果仅包含当前角色可访问的菜单,清空或按 Escape 恢复菜单列表,Enter 打开首个匹配项。 +- 左侧菜单支持按名称和常用关键词搜索(如 WebDAV、备份);桌面直接筛选,手机点击搜索图标打开菜单选择面板。搜索结果仅包含当前角色可访问的菜单,清空或按 Escape 恢复菜单列表,Enter 打开首个匹配项。 - 宽表格和拖拽画布在各自区域横向滚动,1050px 及以下取消固定表格操作列,避免遮挡数据。 - 输入、日期选择器、文件控件和长名称限制在容器内,手机端筛选控件使用整行宽度。 diff --git a/docs/s3-sync.md b/docs/s3-sync.md index 34d84eb..ef400eb 100644 --- a/docs/s3-sync.md +++ b/docs/s3-sync.md @@ -1,41 +1,5 @@ -# S3 多端同步 +# S3 同步迁移说明 -从侧边栏「多端同步」进入 S3 同步页面并填写连接信息。各设备使用同一个 Bucket、目录前缀和同步口令,点击「保存 S3 设置」后「立即同步」。可以启用自动同步,默认每 5 分钟检查一次;服务停止期间不执行。 +S3 多端同步已由 [WebDAV 同步备份](webdav-sync.md) 替代。原页面、自动同步及 `/api/v1/configuration/s3/*` API 不再启用。 -手动导出、加密备份、导入、模板和配置历史统一放在「配置管理」页面;「监控运维」用于查看运行指标、健康检查、活跃会话、告警和 API Token。 - -同步范围:目标、代理出口、资产内容和凭据,以及网关监听地址、端口、访问控制和账号映射。新增、修改、删除均会传播。审计日志、个人登录凭据、配置历史、设备 SSH 身份、S3 设置和网关运行状态保留在各设备本地。 - -## 连接与存储 - -- AWS:Endpoint 留空,Region 与 Bucket 必填。兼容 S3 服务填写 HTTP(S) origin;MinIO 通常使用 Path-style。 -- 凭据需有共享对象的 `s3:GetObject` 和 `s3:PutObject` 权限;不需要列目录或删除对象权限。服务必须支持 ETag、If-Match 和 If-None-Match 条件写入。 -- 共享对象为 `/sync/configuration.json`,每个同步目录对应一组设备。不要用对象生命周期规则定期删除这个文件。可另外启用 Bucket 版本控制。 -- 配置在上传前使用 AES-256-GCM 加密,PBKDF2-SHA256(600000 次)派生密钥,每次使用随机盐和 nonce。同步口令为 12–1024 字节;本机保存的口令、访问密钥由本机 vault 加密。 -- 手动同步可填写本次口令。自动同步必须保存口令。所有设备必须使用相同口令;更换口令应新建同步目录并在所有设备重新配置。 - -## 同步和冲突 - -远端不存在且本机尚未同步时,创建共享对象。新设备配置为空时自动拉取;两端配置相同时记录一致状态。首次接入时两端均有不同内容,会要求选择保留哪一端。 - -之后根据上次同步内容判断变化:仅本机变化时推送,仅远端变化时拉取;两端均变化则暂停并显示冲突。本版本按完整配置选择「保留本机配置」或「采用远端配置」,不自动合并字段。选择会替换另一端的完整同步范围,包含删除。选择期间任一端再次修改会使旧选择失效,需要重新同步确认。 - -推送携带读取时的 ETag;其他设备先写入时,本次推送失败而不会覆盖。远端对象被删除时不会视为空配置直接清空本机,而是提示冲突,可确认保留本机配置重新建立同步。 - -应用远端配置前需要停止本机全部网关,否则显示待处理提示,待停止后重试。应用后网关保持停止;启动前确认同步来的监听 IP 在当前设备上可用。设备 SSH 服务身份保持本机原值。 - -同步替换和基线记录在同一数据库事务中完成,保留旧资源的本地配置历史。自动同步默认停用;旧版 S3 上传备份设置只沿用连接信息,不沿用自动任务或口令,旧文件也不删除。独立下载加密备份功能仍可用于归档。 - -## API - -需要个人会话和写请求 CSRF 校验,禁止 API Token,响应不缓存。 - -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| GET / PUT | `/api/v1/configuration/s3/settings` | 读取脱敏设置 / 保存同步设置 | -| GET | `/api/v1/configuration/s3/status` | 最近同步、下次执行、冲突状态 | -| POST | `/api/v1/configuration/s3/sync` | 执行同步 | - -同步请求可含 `passphrase`,留空使用已保存口令。处理冲突时额外传 `resolution: "local" / "remote"` 及状态接口返回的 `localHash`、`remoteHash`。成功返回 `action: "pushed" / "pulled" / "unchanged"`。旧 `/s3/backup` 上传入口已移除。 - -测试使用进程内 S3 HTTP fixture 和隔离数据库,未连接真实 AWS 或 MinIO。 +旧 S3 设置、状态和远端文件保留,不自动迁移或继续上传。WebDAV 使用独立设置与基线,自动同步默认关闭;请按新文档重新配置。部署前须停服并备份完整数据目录。 diff --git a/docs/single-file-storage.md b/docs/single-file-storage.md index 6eecc32..79c648e 100644 --- a/docs/single-file-storage.md +++ b/docs/single-file-storage.md @@ -27,7 +27,7 @@ SQLite 在运行、写入或异常退出后可能存在 `-wal`、`-shm`、`-jour 自动证书默认覆盖 localhost、127.0.0.1、::1,有效期一年。停止服务后运行 `serviceport export-ca`,标准输出为公开 CA PEM,可另存后配置客户端信任,私钥不导出。该导出文件只供客户端使用,不是服务启动依赖。 -显式的 `SERVICEPORT_TLS_CERT` / `SERVICEPORT_TLS_KEY` 自定义证书仍优先,适用于正式域名/证书轮换;使用者主动配置的外部文件仍需自行管理。目标中主动填写的 SSH/kubeconfig 等文件路径也不会自动吸收入库;要完全可移植,请上传为文件资产并使用资产引用。S3 配置备份仍是原配置包语义,不等同于包含审计和主密钥的 SQLite 灾备。 +显式的 `SERVICEPORT_TLS_CERT` / `SERVICEPORT_TLS_KEY` 自定义证书仍优先,适用于正式域名/证书轮换;使用者主动配置的外部文件仍需自行管理。目标中主动填写的 SSH/kubeconfig 等文件路径也不会自动吸收入库;要完全可移植,请上传为文件资产并使用资产引用。WebDAV 配置备份仍是原配置包语义,不等同于包含审计和主密钥的 SQLite 灾备。 ## 验证 diff --git a/docs/sqlite.md b/docs/sqlite.md index 39fae24..c3824f1 100644 --- a/docs/sqlite.md +++ b/docs/sqlite.md @@ -2,7 +2,7 @@ 在侧边栏选择 **SQLite 文件**,填写 ServicePort 所在机器上已有数据库的绝对路径,例如 `C:\data\example.sqlite` 或 `/var/data/example.db`,点击“测试连接 / 刷新表”。页面显示当前内置 SQLite 引擎版本、用户表及视图;选择表后自动生成查询,并可展开查看建表或视图定义。点击“执行查询”读取数据,也可自行输入单条 `SELECT` 或 `WITH` 查询。 -文件由运行 ServicePort 的系统账号读取;浏览器连接另一台机器时,路径仍指向服务端。此功能只读打开现有 SQLite 3 文件,不创建空库,不上传文件,不接入网关或代理,也不要求数据库用户名和密码。路径、SQL 和结果仅保留在当前页面内存中,离开页面后清空,不加入配置备份、S3 同步或命令审计。加密 SQLite / SQLCipher 文件不支持。正在使用 WAL 的数据库应保留匹配的 WAL/SHM 文件及所需读取权限,不能只复制主文件后期待包含未检查点的数据。 +文件由运行 ServicePort 的系统账号读取;浏览器连接另一台机器时,路径仍指向服务端。此功能只读打开现有 SQLite 3 文件,不创建空库,不上传文件,不接入网关或代理,也不要求数据库用户名和密码。路径、SQL 和结果仅保留在当前页面内存中,离开页面后清空,不加入配置备份、WebDAV 同步或命令审计。加密 SQLite / SQLCipher 文件不支持。正在使用 WAL 的数据库应保留匹配的 WAL/SHM 文件及所需读取权限,不能只复制主文件后期待包含未检查点的数据。 默认返回前 200 行,可设置 1–1000 行,达到上限且仍有数据时显示截断提示;可通过 `WHERE`、`ORDER BY`、`LIMIT` / `OFFSET` 查询需要的范围。查询超时 5 秒,最多 256 列,SQL 最长 64 KiB,单个 SQLite 值或记录、结果字段内容总量限制为 2 MiB。超过限制报错,不返回看似完整的部分内容。最多列出 1000 个用户表或视图、2 MiB 结构定义;没有用户表时显示空状态。 diff --git a/docs/ssh-command-audit.md b/docs/ssh-command-audit.md index d6e33d8..28cd3c8 100644 --- a/docs/ssh-command-audit.md +++ b/docs/ssh-command-audit.md @@ -20,7 +20,7 @@ - **交互终端**:每条已记录输入关联其提交后、到下一次已记录输入提交或会话关闭之间观测到的输出。包含回显、提示符和可能交错的后台输出;连续粘贴多条命令时,结果也可能落在后面的输入区间。它不保证精确的一条命令对应一份输出,不记录或猜测单条 shell 命令的退出码。PTY 通常已合并标准与错误输出。 - **采集中断**:输出先加密写入,再转发给客户端;审计写入失败会中止转发。未结束的记录显示“尚无结束标记”,可能仍在运行,也可能在服务异常退出时未写入结束状态。 -输出按最多 32 KiB 的块加密保存至原始命令所在的 UTC 日期审计库,不设总输出截断;磁盘占用随输出量增加。输出和结果元数据随原命令的 7 天保留期清理。SFTP 文件内容仍不采集,配置 S3 备份也不包含命令输出;完整审计备份需停止服务并复制完整数据目录。 +输出按最多 32 KiB 的块加密保存至原始命令所在的 UTC 日期审计库,不设总输出截断;磁盘占用随输出量增加。输出和结果元数据随原命令的 7 天保留期清理。SFTP 文件内容仍不采集,配置 WebDAV 备份也不包含命令输出;完整审计备份需停止服务并复制完整数据目录。 详情中的“执行输出”支持刷新、分页加载、通道筛选及完整原始字节下载。页面最多预览约 8 MiB,避免大量输出占满浏览器;该限制不改变已保存内容。预览按 UTF-8 解码并转义控制字符,不执行 ANSI/HTML;二进制或其他编码内容可下载查看。全部通道的下载按网关采集顺序合并,不保证两个远端流之间的精确时间顺序;也可单独下载标准或错误输出。下载只包含开始下载时已保存的块,运行中的命令可稍后再次下载。 diff --git a/docs/target-accounts.md b/docs/target-accounts.md index dba1f55..0307df2 100644 --- a/docs/target-accounts.md +++ b/docs/target-accounts.md @@ -11,6 +11,42 @@ proxy route and SSH host identity. Each account has a stable ID, label, username and password, or SSH private key and passphrase. Between 1 and 32 accounts are supported. There is no separate primary credential form. +## Empty Passwords And Unauthenticated Targets + +In the target editor's accounts tab, a new password-based account may leave its +password empty. A username is still required, and the upstream server decides +whether that account may connect with an empty password. This does not bypass +the server's authentication rules. SSH/SFTP private-key login remains available. + +For an existing account, a blank password input retains the saved password. Select +“使用空密码(清除已保存密码)” to explicitly use an empty password. Draft tests and saving +both send `password: ""` when selected; testing alone does not erase the stored +secret. Unselected empty inputs omit the field and preserve the existing value. + +MongoDB, Redis, HTTP and HTTPS accounts additionally offer “无认证”. This stores +`authMethod: "none"`, hides username/password inputs and clears the account's +credentials and key/certificate references when saved. The account ID remains, +so gateway account mappings do not need to change. MongoDB skips upstream SCRAM; +Redis skips upstream AUTH; HTTP(S) sends no upstream Basic credentials and strips +the gateway Authorization header. Credential expiration and gateway login rules +remain enforced. Authentication failures never automatically retry anonymously. + +An unauthenticated target must actually permit the requested operation. A successful +handshake or metadata test does not grant access to all databases or commands. +Other named-account protocols do not offer anonymous mode; use a valid username +and an empty password only when permitted by their server configuration. TCP +mapping remains account-free and is not changed by this feature. + +Importing a URL containing credentials switches the selected account back to +password mode and cancels the explicit empty-password choice. URLs without +credentials retain the existing authentication mode. Existing configurations are +unchanged; this uses the existing encrypted account JSON, with no schema migration. +Before downgrading to a version without anonymous-account support, reconfigure +accounts using `authMethod: "none"`. See [testing](testing.md) and +[change rationale](change-rationale.md) for validation scope. + +## Gateway Account Mapping + Each gateway account may select its own same-protocol target service and account. The gateway editor's accounts tab shows one account at a time. Use the searchable account selector to switch accounts; “添加账号” immediately selects the new account @@ -28,7 +64,7 @@ An empty account `targetId` inherits the gateway default. An explicit `targetId` requires an account belonging to that target; it never inherits an account from the default target, even when account IDs match. A gateway needs no default target when every enabled account has an independent mapping. Each route uses its own -endpoint, outbound transport, TLS settings and credentials. Backups and S3 sync +endpoint, outbound transport, TLS settings and credentials. Backups and WebDAV sync include these mappings; referenced targets and credentials remain protected. Drag an individual gateway account in “拖拽连线” to change only its route. Each diff --git a/docs/target-connection-test.md b/docs/target-connection-test.md index da6b31d..87346dc 100644 --- a/docs/target-connection-test.md +++ b/docs/target-connection-test.md @@ -6,10 +6,11 @@ ## 使用方式与行为 -在目标服务编辑窗口填写连接参数,选择底部的测试账号,点击“测试连接”。只有一个账号时自动选中;多个账号时可以选择尚未保存的新账号。测试不要求先填写目标名称和账号名称,仍要求有效地址、端口、用户名及协议所需凭据。 +在目标服务编辑窗口填写连接参数,选择底部的测试账号,点击“测试连接”。只有一个账号时自动选中;多个账号时可以选择尚未保存的新账号。测试不要求先填写目标名称和账号名称,仍要求有效地址、端口及所选认证方式需要的凭据。MongoDB、Redis、HTTP(S) 选择“无认证”时不要求用户名或密码;其他具名账号可使用空密码,是否成功由目标服务决定。 - 使用点击时的地址、端口、TLS/CA、认证库、集群节点、代理和所选账号快照。MongoDB 连接串解析后即可测试副本集参数。 - 已有账号的密码、私钥或口令留空时,按账号 ID 使用已保存凭据;填写新值只用于本次测试。私钥、证书、CA、Kubeconfig 资产及代理引用在服务端解析。 +- 勾选“使用空密码(清除已保存密码)”时测试显式空密码;选择“无认证”时测试不发送该账号原有凭据。两者均只影响当前草稿,点击保存后才清除已存密码或凭据。网关登录认证不受影响,认证失败不会降级为无认证重试。 - 未选中且未完成的其他账号不阻止测试;集群使用独立 Sentinel 认证账号时也校验并带入该账号。 - 测试成功和失败都保留编辑草稿。结果显示本次测试的目标与账号;配置仍需点击“保存配置”才会落库。 - 测试不创建目标、不写配置历史、不保存主机探测记录,也不修改正在使用的网关路由。测试会按现有协议实现连接并认证目标服务;外部服务可能记录连接或认证日志。 diff --git a/docs/testing.md b/docs/testing.md index 14d16f8..a0a9f51 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,23 @@ # 测试与版本矩阵 +## 2026-09-22 WebDAV 同步备份替代 S3 + +- 使用 Go 1.25.0 运行 `go test ./... -count=1 -timeout=120s`:本次变更涉及的 backup、syncjob、store、api、model、MongoDB/HTTP 协议包均通过;全量仅 internal/cluster 的 TestMySQLReplicationAndGroupDiscovery/mysql-group-replication 失败,报 `cluster has no available primary or router`,与本文件此前记录的既有间歇失败一致。本轮未修改该逻辑,不将全量 Go 标记通过。日志 artifacts/webdav-sync/go-test.log。 +- `go vet ./...`、`npm.cmd --prefix web run build` 通过,前端只有现有大 chunk 警告。Vite 完成后恢复 web/dist/.gitkeep,再构建 artifacts/webdav-sync/serviceport.exe,未覆盖正式程序。 +- 设置 SERVICEPORT_E2E_BINARY 指向新程序,使用隔离 SQLite 与 19491/19492 端口:全量 Playwright **113 通过、4 失败(6.4 分钟)**。四项仍为 global-assets-proxy-project:98、kubeconfig-selection:23、target-outbound-selection:3、workspace:98,分别涉及资产草稿、目标字段显示及连接错误文本;与之前合并及空密码版本相同,不宣称全量浏览器通过。日志 artifacts/webdav-sync/full-web.log;最终退出状态见 results.json。 +- 新 WebDAV 两项、目标认证七项均在完整浏览器运行中通过;此前 WebDAV/menu-search/target-auth 专项组合 11 项通过(26.3 秒,focused.log)。覆盖响应式布局、保存和秘密清空、冲突确认、独立备份以及实际隔离 API 到本机 WebDAV 上传的链路。 +- Go HTTP fixture 覆盖目录和路径编码、Basic/匿名、强 ETag、条件创建/覆盖、拒绝重定向与错误正文泄漏、大小限制、两设备同步与冲突、租约、旧 S3 设置隔离。独立备份测试验证加密可解密、凭据可恢复、文件名唯一、共享文件不被访问且基线/冲突不变;API 测试覆盖跨地址/用户名不继承旧密码、显式清除及会话/CSRF/API Token 边界。 +- 协议测试是 Go fixture,端到端 WebDAV 服务是 Node fixture,未连接外部真实 WebDAV 服务,不能据此保证任意服务商的条件写入兼容。智能体只读复核未发现阻断问题;同步和备份共用最近执行状态的边界已写入 docs/webdav-sync.md。 +- 测试进程退出后隔离端口已释放;`git diff --check` 通过。没有部署、提交、修改真实数据库或目标数据。升级说明要求停服备份完整 data 后再运行新版;当前仍保留工作区原有 SSH 指纹改动。 + +## 2026-09-22 目标空密码与无认证连接 + +- Go 1.25.0 `go test ./... -count=1 -timeout=120s` 和 `go vet ./...` 均通过,结果记录 artifacts/target-auth/go-result.json。新增模型/API 用例覆盖仅允许指定协议无认证、具名空密码、显式清除、草稿不落库及网关认证保留。 +- MongoDB 和 HTTP 使用本机上游 fixture,验证无认证请求、网关错误密码拒绝及 Authorization 不泄漏;Redis 无凭据时跳过 AUTH 由现有 fixture 覆盖,没有连接实际生产服务器。 +- 前端构建和新程序构建通过。使用隔离 SQLite/端口运行无认证版本全量 Playwright:112 通过、4 失败(6.4 分钟),日志 artifacts/target-auth/full-web.log。失败为 global-assets-proxy-project:98、kubeconfig-selection:23、target-outbound-selection:3、workspace:98,与此前合并验证相同,涉及资产草稿断言、目标字段显示和连接错误文本,不宣称全量通过。 +- 随后与 WebDAV 专项一起运行 target-auth.spec.ts 全部 7 项通过(组合共 11 项,artifacts/webdav-sync/focused.log),包括新建空密码、无认证表单/草稿、旧账号默认模式、显式清除、协议限制及手机布局。浏览器部分远程结果使用模拟,不代表真实目标兼容验证。 +- 未部署或修改实际目标数据,保留工作区已有 SSH 跳过指纹改动;本项无需数据库迁移。 + ## 2026-09-22 合并 origin/main 943ea75 - 前端 `npm.cmd --prefix web run build` 通过(仅大 chunk 提示);Windows Go 1.25.0 新程序构建通过,恢复并保留 web/dist/.gitkeep。 diff --git a/docs/webdav-sync.md b/docs/webdav-sync.md new file mode 100644 index 0000000..4dcf667 --- /dev/null +++ b/docs/webdav-sync.md @@ -0,0 +1,80 @@ +# WebDAV 同步备份 + +从侧边栏「多端同步」进入 WebDAV 页面。先保存连接设置,再选择「立即同步」或「立即备份本机」。配置管理中的本地加密导出、导入、模板和历史恢复保持不变。 + +## 连接设置 + +- WebDAV 地址填写已有基础目录的完整 HTTP(S) 地址,例如 `https://dav.example.com/remote.php/dav/files/user/`,而不是仅填写网站首页。基础目录必须存在;不要包含用户名密码、查询参数或 URL 片段。 +- 目录前缀默认为 `serviceport/`,允许为空或相对多级目录,不允许绝对路径、`..`、反斜杠或连续斜杠。上传时通过 MKCOL 创建前缀下需要的子目录,不创建基础目录。 +- 支持 Basic 用户名/密码(包括服务商应用密码);匿名目录将用户名和密码均留空。建议 HTTPS;HTTP 的登录信息可能被窃听。不支持自动跳转、Digest、OAuth 或自签名证书跳过校验。 +- 编辑已保存设置时,登录密码留空且地址、用户名均未改变才保留旧密码;更改地址或用户名后需要重新填写。勾选清除密码可显式移除旧密码。 +- **加密口令不是 WebDAV 登录密码**。口令为 12–1024 字节;所有同步设备使用相同口令。保存口令留空保留原值;手动操作可填写仅本次使用的口令,自动同步必须保存口令。更换口令应使用新的前缀,并在所有设备重新配置。 +- 自动同步默认关闭,开启后默认每 5 分钟执行,允许 1–10080 分钟。服务停止期间不执行,重启不补跑全部错过的周期。手动同步或备份会更新最近执行时间,下次自动同步从该时间重新计算。 + +登录密码与保存的加密口令在本机加密存储,接口仅返回是否已保存,不回传秘密。上传前使用 AES-256-GCM 加密,PBKDF2-SHA256(600000 次)派生密钥,每次使用随机盐和 nonce。明文配置需小于 16 MiB(预留加密认证标签空间),远端加密文件上限 24 MiB。 + +## 多端同步 + +各设备使用相同基础地址、目录前缀和加密口令;共享文件为 `/sync/configuration.json`。 + +同步范围包含目标、代理、资产文件及凭据、项目/环境选项、网关监听配置、访问控制和账号映射,新增、修改、删除均会传播。审计日志、个人登录凭据、API Token、配置历史、设备 SSH 身份、WebDAV 设置与网关运行状态保留在设备本地;外部文件路径不会自动转成文件资产。 + +- 初次同步且远端无文件时上传本机配置;本机为空时可拉取远端,两端相同则记录一致状态。初次两端均有不同配置时要求选择。 +- 此后基于上次同步内容判断:仅本机变化时推送,仅远端变化时拉取;两端均变化则暂停并显示冲突。 +- 冲突选择「保留本机配置」或「采用远端配置」作用于完整同步范围,包含删除,不进行字段合并。选择期间任一端再次变化会使旧选择失效,需要重新同步确认。 +- 远端共享文件被删除不会直接清空本机,而是提示冲突,可确认保留本机配置重新建立共享文件。 +- 应用远端配置前必须停止本机全部网关;应用后网关仍保持停止,启动前核对监听 IP 和端口。设备 SSH 身份不随远端替换。 + +服务需支持 GET、PUT、MKCOL,读取已有同步文件时返回强 ETag,并正确执行 `If-Match` / `If-None-Match: *` 条件写入。不支持这些条件的服务不能安全多端同步,不提供强制覆盖降级。PUT 响应可不含 ETag,下一次读取仍要求强 ETag。目录创建接受 201 或表示目录已存在的 405,其余错误终止操作。重定向不自动跟随,服务商错误响应正文不进入界面或日志。 + +同步配置替换和基线写入沿用同一个短数据库事务,确保本地内容与基线一致;设置更新与基线清除也保持原子性。网络传输不放入数据库事务,任务通过设备锁和数据库租约避免重入。 + +具体比较规范化后配置的 SHA-256,而不是含随机盐的密文,也不使用设备时间决定哪端胜出。记本机哈希为 L、远端为 R、本机上次同步基线为 B;已有基线且远端文件存在时: + +| 条件 | 动作 | +| --- | --- | +| L = R | 无需传输,更新一致基线 | +| L ≠ B,R = B | 推送本机 | +| L = B,R ≠ B | 拉取远端 | +| L ≠ R,且两端均不等于 B | 暂停并要求人工选择 | + +更改 WebDAV 地址、前缀或用户名会重置本机基线,按首次接入重新判断,不沿用其他目录的同步历史。 + +## 独立加密备份与恢复 + +「立即备份本机」在 `/backups/-<随机值>.json` 创建独立加密文件,不读取或修改共享同步文件,不替换本机配置,不推进同步基线,也不消除已有冲突。每次备份使用不同文件名和条件创建,避免覆盖旧备份。其内容范围与同步快照一致;它不是包含主密钥、审计的完整 SQLite 灾备。 + +页面最近执行时间、成功/错误和动作记录当前由同步与备份共用;成功备份会更新最近成功并清除上次任务错误,但不代表同步冲突已解决或远端同步成功,冲突标志仍保留。 + +恢复方法: + +1. 用自己的 WebDAV 客户端下载所需备份文件。 +2. 打开「配置管理」导入该 JSON 文件,输入备份时使用的加密口令。 +3. 先查看导入预览,再确认导入;按配置导入规则处理资源,而非静默整体替换数据库。详见 [配置备份](configuration-backup.md)。 + +当前不提供远端文件列表、在线选择恢复、自动备份轮转或自动删除;独立快照由用户手动触发,定时开关仅执行多端同步。备份留存及清理使用 WebDAV 客户端管理,不要误删共享同步文件。运行实例的完整灾备仍需停服并备份完整数据目录。 + +## 从 S3 升级 + +WebDAV 替代原 S3 页面、调度任务和 API,旧 `/api/v1/configuration/s3/*` 路由返回 404。旧 S3 设置、状态表和远端文件保留,不删除、不自动迁移或继续上传;WebDAV 独立初始化设置及同步基线,自动同步默认关闭。 + +部署新版本前先停止自己管理的本地服务,备份完整 `data` 目录。首次运行新版本会新增 `webdav_sync_settings` / `webdav_sync_state` 表,不覆盖旧 S3 数据。随后填写 WebDAV 地址与凭据,建议使用空的新前缀,并先手动备份或同步确认。旧版回退可能重新使用旧 S3 自动设置,回退前需检查旧配置,不能假定 WebDAV 已迁移回 S3。 + +## API + +需要个人会话;写请求必须通过 CSRF 校验,禁止 API Token,响应不缓存。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET / PUT | `/api/v1/configuration/webdav/settings` | 读取脱敏设置 / 保存连接及调度设置 | +| GET | `/api/v1/configuration/webdav/status` | 最近执行、下次运行、冲突状态 | +| POST | `/api/v1/configuration/webdav/sync` | 手动同步 | +| POST | `/api/v1/configuration/webdav/backup` | 独立备份本机 | + +设置字段为 `endpoint`、`prefix`、`username`、`password`、`clearPassword`、`passphrase`、`automatic`、`intervalMinutes`。只读秘密状态为 `hasPassword`、`hasPassphrase`。 + +同步与备份请求可含 `passphrase`,留空使用保存值;无额外参数时仍发送 `{}`。处理同步冲突时传 `resolution: "local" / "remote"` 及状态接口返回的 `localHash`、`remoteHash`,独立备份不接受冲突选择。成功结果包含 `endpoint`、`key` 和 `action`:同步为 `pushed` / `pulled` / `unchanged`,备份为 `backed_up`。 + +## 验证边界 + +协议和双端同步测试使用 Go HTTP fixture,浏览器端到端使用新构建程序、隔离数据库和本机 Node WebDAV fixture,覆盖真实管理 API 到上传的链路,但未连接外部真实 WebDAV 服务。实际全量与专项测试结果见 [测试记录](testing.md)。 diff --git a/docs/workspace-ui.md b/docs/workspace-ui.md index 9eb335e..83ba198 100644 --- a/docs/workspace-ui.md +++ b/docs/workspace-ui.md @@ -46,7 +46,7 @@ | `serviceport.sidebar-width` | 桌面侧栏像素宽度 | | `serviceport.columns.v1.<表格 ID>` | 列 ID、显隐、顺序、宽度 | -浏览器禁用存储时功能仍可在当前会话使用;这些偏好不进入服务器备份或 S3 同步。所有标签、原始 URL、未保存表单、解密内容和诊断结果不写入浏览器持久存储。不引入新运行依赖、不更换管理 API、不改变网关账号权限或协议安全策略。 +浏览器禁用存储时功能仍可在当前会话使用;这些偏好不进入服务器备份或 WebDAV 同步。所有标签、原始 URL、未保存表单、解密内容和诊断结果不写入浏览器持久存储。不引入新运行依赖、不更换管理 API、不改变网关账号权限或协议安全策略。 ## 验证 diff --git a/internal/api/s3_sync.go b/internal/api/s3_sync.go deleted file mode 100644 index 5ae7b1c..0000000 --- a/internal/api/s3_sync.go +++ /dev/null @@ -1,106 +0,0 @@ -package api - -import ( - "errors" - "net/http" - "serviceport/internal/store" - "serviceport/internal/syncjob" -) - -func s3SettingsView(c store.S3SyncSettings) map[string]any { - return map[string]any{ - "endpoint": c.Endpoint, "region": c.Region, "bucket": c.Bucket, - "prefix": c.Prefix, "pathStyle": c.PathStyle, "accessKeyId": c.AccessKeyID, - "hasSecretAccessKey": c.SecretAccessKey != "", "hasSessionToken": c.SessionToken != "", - "automatic": c.Automatic, "intervalMinutes": c.IntervalMinutes, "hasPassphrase": c.Passphrase != "", - } -} - -func (s *Server) s3Sync(w http.ResponseWriter, r *http.Request, path string) { - switch path { - case "configuration/s3/settings": - if r.Method != http.MethodGet && r.Method != http.MethodPut { - fail(w, 405, "method not allowed") - return - } - s.ops.Lock() - defer s.ops.Unlock() - c, err := s.store.S3SyncSettings() - if err != nil { - fail(w, 500, "cannot load S3 sync settings") - return - } - if r.Method == http.MethodPut { - var body struct { - store.S3SyncSettings - ClearSessionToken bool `json:"clearSessionToken"` - } - if !decode(w, r, &body) { - return - } - if body.SecretAccessKey == "" { - body.SecretAccessKey = c.SecretAccessKey - } - if body.ClearSessionToken { - body.SessionToken = "" - } else if body.SessionToken == "" { - body.SessionToken = c.SessionToken - } - if body.Passphrase == "" { - body.Passphrase = c.Passphrase - } - - if err = body.S3SyncSettings.Validate(); err != nil { - fail(w, 400, err.Error()) - return - } - if err = s.store.SaveS3SyncSettings(body.S3SyncSettings); err != nil { - fail(w, 500, "cannot save S3 sync settings") - return - } - c = body.S3SyncSettings - s.store.Event("", source(r), "s3_sync_settings_updated", "") - } - respond(w, 200, s3SettingsView(c)) - case "configuration/s3/status": - if r.Method != http.MethodGet { - fail(w, 405, "method not allowed") - return - } - c, err := s.store.S3SyncSettings() - if err != nil { - fail(w, 500, "cannot load S3 sync settings") - return - } - state, err := s.store.S3SyncState() - if err != nil { - fail(w, 500, "cannot load S3 sync status") - return - } - respond(w, 200, map[string]any{"state": state, "nextRun": syncjob.NextRun(c, state)}) - case "configuration/s3/sync": - if r.Method != http.MethodPost { - fail(w, 405, "method not allowed") - return - } - var body syncjob.Request - if !decode(w, r, &body) { - return - } - result, err := s.syncer.Run(r.Context(), source(r), body) - if err != nil { - status := 502 - if errors.Is(err, syncjob.ErrBusy) || errors.Is(err, syncjob.ErrConflict) || errors.Is(err, syncjob.ErrRunning) { - status = 409 - } - if errors.Is(err, syncjob.ErrSettings) || errors.Is(err, syncjob.ErrPassphrase) { - status = 400 - } - fail(w, status, err.Error()) - return - } - respond(w, 200, result) - default: - fail(w, 404, "not found") - } -} diff --git a/internal/api/s3_sync_test.go b/internal/api/s3_sync_test.go deleted file mode 100644 index 4ecf03a..0000000 --- a/internal/api/s3_sync_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package api - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "serviceport/internal/backup" - "serviceport/internal/store" - "strings" - "testing" -) - -func TestS3SyncSettingsAndAuthorization(t *testing.T) { - f := newFixture(t) - path := "/api/v1/configuration/s3/settings" - requireStatus(t, f.request("GET", path, nil, false), 401) - f.login() - body := store.S3SyncSettings{S3Config: backup.S3Config{Region: "us-east-1", Bucket: "test-bucket", PathStyle: true, AccessKeyID: "access", SecretAccessKey: "secret-never-return", SessionToken: "token-never-return"}, IntervalMinutes: 5, Passphrase: "pass-never-return"} - requireStatus(t, f.request("PUT", path, body, false), 403) - w := f.request("PUT", path, body, true) - requireStatus(t, w, 200) - for _, secret := range []string{body.SecretAccessKey, body.SessionToken, body.Passphrase} { - if strings.Contains(w.Body.String(), secret) { - t.Fatal("settings echoed secrets") - } - } - w = f.request("GET", path, nil, false) - requireStatus(t, w, 200) - if w.Header().Get("Cache-Control") != "no-store" { - t.Fatal("settings can be cached") - } - var view map[string]any - if json.Unmarshal(w.Body.Bytes(), &view) != nil || view["hasPassphrase"] != true { - t.Fatal("manual sync passphrase was not retained") - } - body.SecretAccessKey = "" - body.SessionToken = "" - body.Passphrase = "" - body.Automatic = true - requireStatus(t, f.request("PUT", path, body, true), 200) - saved, err := f.store.S3SyncSettings() - if err != nil || saved.SecretAccessKey != "secret-never-return" || saved.Passphrase != "pass-never-return" { - t.Fatal("blank secrets were not preserved") - } - body.Automatic = false - body.Passphrase = "short" - requireStatus(t, f.request("PUT", path, body, true), 400) - requireStatus(t, f.request("POST", "/api/v1/configuration/s3/backup", map[string]any{}, true), 404) - requireStatus(t, f.request("POST", "/api/v1/configuration/s3/sync", map[string]any{}, false), 403) - for _, path := range []string{"configuration/s3/settings", "configuration/s3/status", "configuration/s3/sync"} { - if tokenPermits(store.APIToken{Scopes: []string{"read", "write"}}, path, "POST") { - t.Fatal("API token can use sync") - } - } - events, _ := f.store.Events() - raw, _ := json.Marshal(events) - if strings.Contains(string(raw), "never-return") { - t.Fatal("secrets leaked into events") - } -} - -func TestS3SyncEndpointAndNoProviderSecretLeak(t *testing.T) { - f := newFixture(t) - f.login() - service := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" { - w.WriteHeader(404) - return - } - if r.Header.Get("If-None-Match") != "*" { - t.Error("missing conditional creation") - } - w.WriteHeader(403) - w.Write([]byte("provider-echo-secret")) - })) - defer service.Close() - settings := store.S3SyncSettings{S3Config: backup.S3Config{Endpoint: service.URL, Region: "us-east-1", Bucket: "test-bucket", PathStyle: true, AccessKeyID: "key", SecretAccessKey: "secret"}, IntervalMinutes: 5, Passphrase: "shared-sync-passphrase"} - requireStatus(t, f.request("PUT", "/api/v1/configuration/s3/settings", settings, true), 200) - w := f.request("POST", "/api/v1/configuration/s3/sync", map[string]any{}, true) - requireStatus(t, w, 502) - if strings.Contains(w.Body.String(), "provider-echo-secret") { - t.Fatal("provider response leaked") - } - w = f.request("GET", "/api/v1/configuration/s3/status", nil, false) - requireStatus(t, w, 200) - if strings.Contains(w.Body.String(), "provider-echo-secret") { - t.Fatal("provider response persisted") - } -} diff --git a/internal/api/server.go b/internal/api/server.go index 24234b1..5aa66de 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -236,8 +236,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request) { respond(w, 200, items) return } - if strings.HasPrefix(path, "configuration/s3/") { - s.s3Sync(w, r, path) + if strings.HasPrefix(path, "configuration/webdav/") { + s.webdavSync(w, r, path) return } s.ops.Lock() diff --git a/internal/api/target_auth_test.go b/internal/api/target_auth_test.go new file mode 100644 index 0000000..8d0d7fb --- /dev/null +++ b/internal/api/target_auth_test.go @@ -0,0 +1,73 @@ +package api + +import ( + "encoding/json" + "testing" + + "serviceport/internal/gateway" + "serviceport/internal/model" +) + +func TestTargetNoAuthDraftSaveAndClearPassword(t *testing.T) { + fixture := newFixture(t) + fixture.login() + handler := &recordingDraftProtocol{} + handlers := map[string]model.Handler{} + for _, protocol := range []string{"mongodb", "redis", "http", "https", "mysql"} { + handlers[protocol] = handler + } + manager := gateway.New(fixture.store, nil, handlers) + fixture.server.manager = manager + t.Cleanup(manager.Shutdown) + for _, protocol := range []string{"mongodb", "redis", "http", "https"} { + config := draftConfig(protocol) + config["accounts"] = []any{map[string]any{"id": "chosen", "authMethod": "none"}} + response := fixture.request("POST", "/api/v1/targets/test", map[string]any{"accountId": "chosen", "config": config}, true) + requireStatus(t, response, 200) + seen := handler.seen[len(handler.seen)-1] + if seen.TargetUser != "" || seen.TargetPassword != "" || seen.SSH.AuthMethod != "none" { + t.Fatal("anonymous draft not applied") + } + config["name"] = "Anonymous " + protocol + config["accounts"] = []any{map[string]any{"id": "chosen", "name": "Anonymous", "authMethod": "none"}} + response = fixture.request("POST", "/api/v1/targets", config, true) + requireStatus(t, response, 201) + } + config := draftConfig("mysql") + config["name"] = "Clear password" + config["accounts"] = []any{map[string]any{"id": "chosen", "name": "Chosen", "username": "fixture", "password": "saved-secret", "authMethod": "password"}} + response := fixture.request("POST", "/api/v1/targets", config, true) + requireStatus(t, response, 201) + var saved struct { + Config model.Target `json:"config"` + } + if err := json.Unmarshal(response.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + for _, clear := range []bool{false, true} { + account := map[string]any{"id": "chosen", "name": "Chosen", "username": "fixture", "authMethod": "password"} + if clear { + account["password"] = "" + } + config["accounts"] = []any{account} + response = fixture.request("POST", "/api/v1/targets/test", map[string]any{"targetId": saved.Config.ID, "accountId": "chosen", "config": config}, true) + requireStatus(t, response, 200) + expected := "saved-secret" + if clear { + expected = "" + } + if handler.seen[len(handler.seen)-1].TargetPassword != expected { + t.Fatal("draft password semantics changed") + } + stored, err := fixture.store.GetTarget(saved.Config.ID) + if err != nil || stored.Accounts[0].Password != "saved-secret" { + t.Fatal("draft modified stored password") + } + response = fixture.request("PUT", "/api/v1/targets/"+saved.Config.ID, map[string]any{"accounts": []any{account}}, true) + requireStatus(t, response, 200) + stored, err = fixture.store.GetTarget(saved.Config.ID) + if err != nil || stored.Accounts[0].Password != expected { + t.Fatal("saved password semantics changed") + } + } +} diff --git a/internal/api/target_draft.go b/internal/api/target_draft.go index cd8f92f..356bcdd 100644 --- a/internal/api/target_draft.go +++ b/internal/api/target_draft.go @@ -105,6 +105,9 @@ func (s *Server) testTargetDraft(w http.ResponseWriter, r *http.Request) { if a.ID == body.AccountID || a.ID == target.Cluster.SentinelAccountID { if strings.TrimSpace(a.Name) == "" { a.Name = a.Username + if a.AuthMethod == "none" { + a.Name = "无认证账号" + } } accounts = append(accounts, a) } diff --git a/internal/api/tokens.go b/internal/api/tokens.go index 487c454..8c88a93 100644 --- a/internal/api/tokens.go +++ b/internal/api/tokens.go @@ -7,7 +7,7 @@ import ( ) func tokenPermits(t store.APIToken, path, method string) bool { - if strings.HasPrefix(path, "configuration/s3/") || path == "configuration/export" { + if strings.HasPrefix(path, "configuration/webdav/") || strings.HasPrefix(path, "configuration/s3/") || path == "configuration/export" { return false } // Automation credentials cannot create credentials or reveal stored secrets. diff --git a/internal/api/webdav_sync.go b/internal/api/webdav_sync.go new file mode 100644 index 0000000..fa0fa53 --- /dev/null +++ b/internal/api/webdav_sync.go @@ -0,0 +1,103 @@ +package api + +import ( + "errors" + "net/http" + "serviceport/internal/store" + "serviceport/internal/syncjob" +) + +func webdavSettingsView(c store.WebDAVSyncSettings) map[string]any { + return map[string]any{ + "endpoint": c.Endpoint, "prefix": c.Prefix, "username": c.Username, + "hasPassword": c.Password != "", "automatic": c.Automatic, + "intervalMinutes": c.IntervalMinutes, "hasPassphrase": c.Passphrase != "", + } +} + +func (s *Server) webdavSync(w http.ResponseWriter, r *http.Request, path string) { + switch path { + case "configuration/webdav/settings": + if r.Method != http.MethodGet && r.Method != http.MethodPut { + fail(w, 405, "method not allowed") + return + } + s.ops.Lock() + defer s.ops.Unlock() + c, err := s.store.WebDAVSyncSettings() + if err != nil { + fail(w, 500, "cannot load WebDAV sync settings") + return + } + if r.Method == http.MethodPut { + var body struct { + store.WebDAVSyncSettings + ClearPassword bool `json:"clearPassword"` + } + if !decode(w, r, &body) { + return + } + if body.ClearPassword { + body.Password = "" + } else if body.Password == "" && body.Endpoint == c.Endpoint && body.Username == c.Username { + body.Password = c.Password + } + if body.Passphrase == "" { + body.Passphrase = c.Passphrase + } + + if err = body.WebDAVSyncSettings.Validate(); err != nil { + fail(w, 400, err.Error()) + return + } + if err = s.store.SaveWebDAVSyncSettings(body.WebDAVSyncSettings); err != nil { + fail(w, 500, "cannot save WebDAV sync settings") + return + } + c = body.WebDAVSyncSettings + s.store.Event("", source(r), "webdav_sync_settings_updated", "") + } + respond(w, 200, webdavSettingsView(c)) + case "configuration/webdav/status": + if r.Method != http.MethodGet { + fail(w, 405, "method not allowed") + return + } + c, err := s.store.WebDAVSyncSettings() + if err != nil { + fail(w, 500, "cannot load WebDAV sync settings") + return + } + state, err := s.store.WebDAVSyncState() + if err != nil { + fail(w, 500, "cannot load WebDAV sync status") + return + } + respond(w, 200, map[string]any{"state": state, "nextRun": syncjob.NextRun(c, state)}) + case "configuration/webdav/sync", "configuration/webdav/backup": + if r.Method != http.MethodPost { + fail(w, 405, "method not allowed") + return + } + var body syncjob.Request + if !decode(w, r, &body) { + return + } + body.BackupOnly = path == "configuration/webdav/backup" + result, err := s.syncer.Run(r.Context(), source(r), body) + if err != nil { + status := 502 + if errors.Is(err, syncjob.ErrBusy) || errors.Is(err, syncjob.ErrConflict) || errors.Is(err, syncjob.ErrRunning) { + status = 409 + } + if errors.Is(err, syncjob.ErrSettings) || errors.Is(err, syncjob.ErrPassphrase) { + status = 400 + } + fail(w, status, err.Error()) + return + } + respond(w, 200, result) + default: + fail(w, 404, "not found") + } +} diff --git a/internal/api/webdav_sync_test.go b/internal/api/webdav_sync_test.go new file mode 100644 index 0000000..99c3005 --- /dev/null +++ b/internal/api/webdav_sync_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "serviceport/internal/backup" + "serviceport/internal/store" + "strings" + "testing" +) + +func TestWebDAVSyncSettingsAndAuthorization(t *testing.T) { + f := newFixture(t) + path := "/api/v1/configuration/webdav/settings" + requireStatus(t, f.request("GET", path, nil, false), 401) + f.login() + body := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: "https://dav.example.com/root/", Username: "access", Password: "secret-never-return"}, IntervalMinutes: 5, Passphrase: "pass-never-return"} + requireStatus(t, f.request("PUT", path, body, false), 403) + w := f.request("PUT", path, body, true) + requireStatus(t, w, 200) + for _, secret := range []string{body.Password, body.Passphrase} { + if strings.Contains(w.Body.String(), secret) { + t.Fatal("settings echoed secrets") + } + } + w = f.request("GET", path, nil, false) + requireStatus(t, w, 200) + if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("settings can be cached") + } + var view map[string]any + if json.Unmarshal(w.Body.Bytes(), &view) != nil || view["hasPassphrase"] != true { + t.Fatal("manual sync passphrase was not retained") + } + body.Password = "" + body.Passphrase = "" + body.Automatic = true + requireStatus(t, f.request("PUT", path, body, true), 200) + saved, err := f.store.WebDAVSyncSettings() + if err != nil || saved.Password != "secret-never-return" || saved.Passphrase != "pass-never-return" { + t.Fatal("blank secrets were not preserved") + } + body.Endpoint = "https://another.example.com/dav/" + requireStatus(t, f.request("PUT", path, body, true), 200) + saved, err = f.store.WebDAVSyncSettings() + if err != nil || saved.Password != "" { + t.Fatal("password inherited by another WebDAV server") + } + body.Password = "replacement-secret" + requireStatus(t, f.request("PUT", path, body, true), 200) + body.Password = "" + body.Username = "another-user" + requireStatus(t, f.request("PUT", path, body, true), 200) + saved, err = f.store.WebDAVSyncSettings() + if err != nil || saved.Password != "" { + t.Fatal("password inherited by another WebDAV user") + } + body.Password = "replacement-secret" + requireStatus(t, f.request("PUT", path, body, true), 200) + requireStatus(t, f.request("PUT", path, map[string]any{ + "endpoint": body.Endpoint, "username": body.Username, + "intervalMinutes": 5, "clearPassword": true, + }, true), 200) + saved, err = f.store.WebDAVSyncSettings() + if err != nil || saved.Password != "" || saved.Passphrase != "pass-never-return" { + t.Fatal("explicit password clearing changed the encryption passphrase") + } + body.Automatic = false + body.Passphrase = "short" + requireStatus(t, f.request("PUT", path, body, true), 400) + requireStatus(t, f.request("POST", "/api/v1/configuration/s3/backup", map[string]any{}, true), 404) + requireStatus(t, f.request("GET", "/api/v1/configuration/s3/settings", nil, false), 404) + requireStatus(t, f.request("POST", "/api/v1/configuration/webdav/backup", map[string]any{}, false), 403) + requireStatus(t, f.request("POST", "/api/v1/configuration/webdav/sync", map[string]any{}, false), 403) + for _, path := range []string{"configuration/webdav/settings", "configuration/webdav/status", "configuration/webdav/sync", "configuration/webdav/backup"} { + if tokenPermits(store.APIToken{Scopes: []string{"read", "write"}}, path, "POST") { + t.Fatal("API token can use sync") + } + } + events, _ := f.store.Events() + raw, _ := json.Marshal(events) + if strings.Contains(string(raw), "never-return") { + t.Fatal("secrets leaked into events") + } +} + +func TestWebDAVSyncEndpointAndNoProviderSecretLeak(t *testing.T) { + f := newFixture(t) + f.login() + service := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "MKCOL" { + w.WriteHeader(201) + return + } + if r.Method == "GET" { + w.WriteHeader(404) + return + } + if r.Header.Get("If-None-Match") != "*" { + t.Error("missing conditional creation") + } + w.WriteHeader(403) + w.Write([]byte("provider-echo-secret")) + })) + defer service.Close() + settings := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: service.URL, Username: "key", Password: "secret"}, IntervalMinutes: 5, Passphrase: "shared-sync-passphrase"} + requireStatus(t, f.request("PUT", "/api/v1/configuration/webdav/settings", settings, true), 200) + w := f.request("POST", "/api/v1/configuration/webdav/sync", map[string]any{}, true) + requireStatus(t, w, 502) + if strings.Contains(w.Body.String(), "provider-echo-secret") { + t.Fatal("provider response leaked") + } + w = f.request("GET", "/api/v1/configuration/webdav/status", nil, false) + requireStatus(t, w, 200) + if strings.Contains(w.Body.String(), "provider-echo-secret") { + t.Fatal("provider response persisted") + } +} diff --git a/internal/backup/webdav.go b/internal/backup/webdav.go new file mode 100644 index 0000000..744d20c --- /dev/null +++ b/internal/backup/webdav.go @@ -0,0 +1,143 @@ +package backup + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type WebDAVConfig struct { + Endpoint string `json:"endpoint"` + Prefix string `json:"prefix"` + Username string `json:"username"` + Password string `json:"password"` +} + +func validDAVPath(path string) bool { + if strings.ContainsAny(path, "\\\r\n\x00") { + return false + } + for _, segment := range strings.Split(path, "/") { + if segment == "." || segment == ".." { + return false + } + } + return true +} + +func (config WebDAVConfig) Validate() error { + endpoint, err := url.Parse(config.Endpoint) + if err != nil || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.Hostname() == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" || len(config.Endpoint) > 2048 || !validDAVPath(endpoint.Path) { + return errors.New("WebDAV 地址必须是有效的 HTTP(S) 目录地址,不含账号、查询参数或片段") + } + if len(config.Prefix) > 512 || strings.HasPrefix(config.Prefix, "/") || strings.Contains(config.Prefix, "//") || !validDAVPath(config.Prefix) { + return errors.New("WebDAV 目录必须是最多 512 字节的相对路径,不能包含 .. 或反斜杠") + } + if len(config.Username) > 256 || strings.ContainsAny(config.Username, ":\r\n\x00") || (config.Username != "" && strings.TrimSpace(config.Username) == "") || len(config.Password) > 4096 || (config.Username == "" && config.Password != "") { + return errors.New("WebDAV 用户名或密码无效;匿名访问时两者均应为空") + } + return nil +} + +func davRequest(ctx context.Context, config WebDAVConfig, method, key string, payload []byte) (*http.Request, error) { + endpoint, err := url.Parse(config.Endpoint) + if err != nil { + return nil, err + } + endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/" + key + endpoint.RawPath = "" + request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), bytes.NewReader(payload)) + if err != nil { + return nil, err + } + if config.Username != "" { + request.SetBasicAuth(config.Username, config.Password) + } + return request, nil +} + +func strongDAVTag(tag string) bool { + return len(tag) >= 2 && len(tag) <= 1024 && tag[0] == '"' && tag[len(tag)-1] == '"' && !strings.ContainsAny(tag[1:len(tag)-1], "\"\r\n\x00") +} + +func davCollections(ctx context.Context, client *http.Client, config WebDAVConfig, key string) error { + parts := strings.Split(key, "/") + for index := 1; index < len(parts); index++ { + request, err := davRequest(ctx, config, "MKCOL", strings.Join(parts[:index], "/")+"/", nil) + if err != nil { + return errors.New("无法创建 WebDAV 目录请求") + } + response, err := client.Do(request) + if err != nil { + return errors.New("WebDAV 目录创建失败,请检查网络、地址和 TLS 证书") + } + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) + response.Body.Close() + if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusMethodNotAllowed { + return fmt.Errorf("WebDAV 创建目录返回 HTTP %d,请检查基础目录及 MKCOL 权限", response.StatusCode) + } + } + return nil +} + +func WebDAVExchange(ctx context.Context, config WebDAVConfig, method, key string, payload []byte, etag string) ([]byte, string, error) { + if err := config.Validate(); err != nil { + return nil, "", err + } + if (method != http.MethodGet && method != http.MethodPut) || key == "" || strings.HasPrefix(key, "/") || strings.HasSuffix(key, "/") || strings.Contains(key, "//") || !validDAVPath(key) || len(key) > 2048 || len(payload) > 24<<20 { + return nil, "", errors.New("无效的 WebDAV 文件请求或文件超过 24 MiB") + } + if etag != "" && !strongDAVTag(etag) { + return nil, "", errors.New("WebDAV 服务未提供有效的强 ETag,无法安全同步") + } + client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + if method == http.MethodPut { + if err := davCollections(ctx, client, config, key); err != nil { + return nil, "", err + } + } + request, err := davRequest(ctx, config, method, key, payload) + if err != nil { + return nil, "", errors.New("无法创建 WebDAV 文件请求") + } + if method == http.MethodPut { + request.Header.Set("Content-Type", "application/json") + if etag == "" { + request.Header.Set("If-None-Match", "*") + } else { + request.Header.Set("If-Match", etag) + } + } + response, err := client.Do(request) + if err != nil { + return nil, "", errors.New("WebDAV 请求失败,请检查网络、目录地址和 TLS 证书;写入超时后请重新同步确认结果") + } + defer response.Body.Close() + if method == http.MethodGet && response.StatusCode == http.StatusNotFound { + return nil, "", ErrNotFound + } + if response.StatusCode == http.StatusPreconditionFailed { + return nil, "", ErrObjectExists + } + if (method == http.MethodGet && response.StatusCode != http.StatusOK) || (method == http.MethodPut && response.StatusCode != http.StatusOK && response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusNoContent) { + return nil, "", fmt.Errorf("WebDAV 返回 HTTP %d,请检查账号、目录及读写权限", response.StatusCode) + } + tag := response.Header.Get("ETag") + if method == http.MethodPut { + return nil, tag, nil + } + if !strongDAVTag(tag) { + return nil, "", errors.New("WebDAV 服务未提供有效的强 ETag,无法安全同步") + } + raw, err := io.ReadAll(io.LimitReader(response.Body, (24<<20)+1)) + if err != nil || len(raw) > 24<<20 { + return nil, "", errors.New("WebDAV 文件读取失败或超过 24 MiB") + } + return raw, tag, nil +} diff --git a/internal/backup/webdav_test.go b/internal/backup/webdav_test.go new file mode 100644 index 0000000..9a76af6 --- /dev/null +++ b/internal/backup/webdav_test.go @@ -0,0 +1,130 @@ +package backup + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWebDAVCollectionsEscapingAndConditionalWrites(t *testing.T) { + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + user, password, ok := request.BasicAuth() + if !ok || user != "fixture" || password != "fixture-secret" { + t.Error("invalid Basic credentials") + } + if !strings.HasPrefix(request.URL.Path, "/dav/账号/team space/") { + t.Errorf("wrong WebDAV path: %s", request.URL.Path) + } + methods = append(methods, request.Method) + if request.Method == "MKCOL" { + response.WriteHeader(201) + return + } + if request.Method == "PUT" { + payload, _ := io.ReadAll(request.Body) + if string(payload) != "ciphertext" { + t.Error("wrong payload") + } + if request.Header.Get("If-Match") != `"revision"` && request.Header.Get("If-None-Match") != "*" { + t.Error("missing conditional write") + } + response.WriteHeader(204) + return + } + response.Header().Set("ETag", `"revision"`) + response.Write([]byte("ciphertext")) + })) + defer server.Close() + config := WebDAVConfig{Endpoint: server.URL + "/dav/%E8%B4%A6%E5%8F%B7/", Username: "fixture", Password: "fixture-secret"} + raw, tag, err := WebDAVExchange(context.Background(), config, "GET", "team space/sync/configuration.json", nil, "") + if err != nil || string(raw) != "ciphertext" || tag != `"revision"` { + t.Fatalf("read: %s %s %v", raw, tag, err) + } + for _, etag := range []string{"", tag} { + if _, _, err := WebDAVExchange(context.Background(), config, "PUT", "team space/sync/configuration.json", []byte("ciphertext"), etag); err != nil { + t.Fatal(err) + } + } + if strings.Join(methods, ",") != "GET,MKCOL,MKCOL,PUT,MKCOL,MKCOL,PUT" { + t.Fatal(methods) + } +} + +func TestWebDAVRejectsUnsafeEndpointsPathsAndResponses(t *testing.T) { + for _, prefix := range []string{"/absolute", "team//nested", "team/../escape", "team\\escape"} { + if (WebDAVConfig{Endpoint: "https://example.com/dav/", Prefix: prefix}).Validate() == nil { + t.Fatalf("accepted invalid prefix %q", prefix) + } + } + for _, endpoint := range []string{"", "ftp://example.com", "https://user:secret@example.com/dav/", "https://example.com/dav/?secret=1", "https://example.com/dav/#fragment", "https://example.com/dav/../escape", "https://example.com/%2e%2e/escape"} { + if (WebDAVConfig{Endpoint: endpoint}).Validate() == nil { + t.Fatalf("accepted invalid endpoint %q", endpoint) + } + } + for _, key := range []string{"../escape", "/escape", "team/../escape", "team\\escape", "team//escape"} { + if _, _, err := WebDAVExchange(context.Background(), WebDAVConfig{Endpoint: "http://127.0.0.1:1"}, "PUT", key, nil, ""); err == nil { + t.Fatal("accepted unsafe file path") + } + } + for _, status := range []int{200, 302, 401, 403, 404, 409, 412} { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Location", "http://127.0.0.1:1/secret") + response.WriteHeader(status) + response.Write([]byte("provider-secret")) + })) + _, _, err := WebDAVExchange(context.Background(), WebDAVConfig{Endpoint: server.URL}, "GET", "sync.json", nil, "") + server.Close() + if err == nil || strings.Contains(err.Error(), "provider-secret") { + t.Fatalf("unsafe response for %d: %v", status, err) + } + if status == 404 && !errors.Is(err, ErrNotFound) { + t.Fatal("missing file not distinguished") + } + if status == 412 && !errors.Is(err, ErrObjectExists) { + t.Fatal("conditional conflict not distinguished") + } + } + for _, tag := range []string{"", `W/"weak"`, "unquoted", `"bad"tag"`} { + if strongDAVTag(tag) { + t.Fatal("accepted unsafe ETag") + } + } +} + +func TestWebDAVRefusesRedirectsAndOversizedReads(t *testing.T) { + foreignCalls := 0 + foreign := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { foreignCalls++ })) + defer foreign.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("Location", foreign.URL) + response.WriteHeader(307) + })) + defer redirect.Close() + for _, method := range []string{"GET", "PUT"} { + _, _, err := WebDAVExchange(context.Background(), WebDAVConfig{Endpoint: redirect.URL, Username: "fixture", Password: "secret"}, method, "directory/file.json", nil, "") + if err == nil { + t.Fatal("accepted redirect") + } + } + if foreignCalls != 0 { + t.Fatal("followed credential-bearing redirect") + } + large := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.Header().Set("ETag", `"large"`) + chunk := make([]byte, 1<<20) + for index := 0; index < 25; index++ { + if _, err := response.Write(chunk); err != nil { + return + } + } + })) + defer large.Close() + if _, _, err := WebDAVExchange(context.Background(), WebDAVConfig{Endpoint: large.URL}, "GET", "large.json", nil, ""); err == nil { + t.Fatal("accepted oversized backup") + } +} diff --git a/internal/model/target_account.go b/internal/model/target_account.go index 4c2e156..28ddd16 100644 --- a/internal/model/target_account.go +++ b/internal/model/target_account.go @@ -24,6 +24,9 @@ type TargetAccount struct { } func (a TargetAccount) Apply(p Proxy) Proxy { + if a.AuthMethod == "none" { + a = a.withoutCredentials() + } p.TargetCredentialExpiresAt = a.ExpiresAt p.TargetCredentialError = nil p.TargetUser, p.TargetPassword = a.Username, a.Password @@ -70,6 +73,12 @@ func (t *Target) ValidateAccounts() error { seen := map[string]bool{} for i := range t.Accounts { a := &t.Accounts[i] + if a.AuthMethod == "none" { + if !SupportsUnauthenticatedTarget(t.Type) { + return errors.New("target protocol does not support unauthenticated accounts") + } + *a = a.withoutCredentials() + } if err := validateCredentialExpiry(a.ExpiresAt, a.RotationReminderDays); err != nil { return err } @@ -136,6 +145,9 @@ func mergeAccountSecrets(current []TargetAccount, patch map[string]any, out *Tar a := &out.Accounts[i] a.PrivateKeyAssetID, a.CertificateAssetID, a.PrivateKey, a.Certificate, a.Passphrase = "", "", "", "", "" } + if out.Accounts[i].AuthMethod == "none" { + out.Accounts[i] = out.Accounts[i].withoutCredentials() + } } return nil } diff --git a/internal/model/target_auth.go b/internal/model/target_auth.go new file mode 100644 index 0000000..a81367d --- /dev/null +++ b/internal/model/target_auth.go @@ -0,0 +1,17 @@ +package model + +func SupportsUnauthenticatedTarget(protocol string) bool { + switch protocol { + case "mongodb", "redis", "http", "https": + return true + default: + return false + } +} + +func (account TargetAccount) withoutCredentials() TargetAccount { + account.Username, account.Password = "", "" + account.PrivateKey, account.PrivateKeyAssetID = "", "" + account.Certificate, account.CertificateAssetID, account.Passphrase = "", "", "" + return account +} diff --git a/internal/model/target_auth_test.go b/internal/model/target_auth_test.go new file mode 100644 index 0000000..ce08a5d --- /dev/null +++ b/internal/model/target_auth_test.go @@ -0,0 +1,75 @@ +package model + +import "testing" + +func TestUnauthenticatedTargetAccounts(t *testing.T) { + for _, protocol := range []string{"mongodb", "redis", "http", "https", "mysql", "pgsql", "sqlserver", "rabbitmq", "ssh", "sftp"} { + t.Run(protocol, func(t *testing.T) { + target := TargetDefaults() + target.Name, target.Type, target.TargetHost, target.TargetPort = "fixture", protocol, "fixture.invalid", 12345 + target.Accounts = []TargetAccount{{ID: "anonymous", Name: "Anonymous", AuthMethod: "none", Username: "old", Password: "old-secret", PrivateKey: "old-key", PrivateKeyAssetID: "old-asset", Certificate: "old-cert", CertificateAssetID: "old-cert-asset", Passphrase: "old-phrase"}} + err := target.Validate() + if !SupportsUnauthenticatedTarget(protocol) { + if err == nil { + t.Fatal("unsupported anonymous protocol accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + account := target.Accounts[0] + if account != account.withoutCredentials() { + t.Fatal("anonymous credentials retained") + } + proxy, err := target.Resolve(Proxy{TargetAccountID: account.ID}) + if err != nil || proxy.TargetUser != "" || proxy.TargetPassword != "" { + t.Fatalf("anonymous resolution: %v", err) + } + probe := account.Apply(target.Runtime()) + probe.ListenPort, probe.GatewayUser, probe.GatewayPassword = 1, "gateway", "gateway-secret" + if err := probe.Validate(); err != nil { + t.Fatal(err) + } + probe.GatewayPassword = "" + if probe.Validate() == nil { + t.Fatal("anonymous target disabled gateway authentication") + } + }) + } +} + +func TestEmptyPasswordAndExplicitSecretClearing(t *testing.T) { + current := Target{Accounts: []TargetAccount{{ID: "chosen", Name: "Chosen", Username: "user", AuthMethod: "password", Password: "secret", PrivateKey: "key", Passphrase: "phrase"}}} + for _, mode := range []string{"retain", "clear", "none"} { + fields := map[string]any{"id": "chosen", "name": "Chosen", "username": "user", "authMethod": "password"} + if mode == "clear" { + fields["password"] = "" + } + if mode == "none" { + fields["authMethod"] = "none" + } + updated, err := MergeTarget(current, map[string]any{"accounts": []any{fields}}) + if err != nil { + t.Fatal(err) + } + account := updated.Accounts[0] + if mode == "retain" && account.Password != "secret" { + t.Fatal("omission did not preserve password") + } + if mode != "retain" && account.Password != "" { + t.Fatal("explicit clearing retained password") + } + if mode == "none" && account != account.withoutCredentials() { + t.Fatal("anonymous merge retained credentials") + } + } + for _, protocol := range []string{"mysql", "pgsql", "sqlserver", "rabbitmq", "ssh", "sftp", "mongodb", "redis", "http", "https"} { + target := TargetDefaults() + target.Name, target.Type, target.TargetHost, target.TargetPort = "fixture", protocol, "fixture.invalid", 12345 + target.Accounts = []TargetAccount{{ID: "empty", Name: "Empty password", Username: "fixture-user", AuthMethod: "password"}} + if err := target.Validate(); err != nil { + t.Fatalf("%s empty password: %v", protocol, err) + } + } +} diff --git a/internal/model/validation.go b/internal/model/validation.go index 9645eda..cb14121 100644 --- a/internal/model/validation.go +++ b/internal/model/validation.go @@ -105,7 +105,8 @@ func (p *Proxy) Validate() error { if p.MaxConnections < 1 || p.MaxConnections > 10000 { return errors.New("maxConnections must be 1-10000") } - if p.Type != "tcp" && (strings.TrimSpace(p.TargetUser) == "" || strings.TrimSpace(p.GatewayUser) == "") { + unauthenticatedTarget := SupportsUnauthenticatedTarget(p.Type) && p.SSH.AuthMethod == "none" + if p.Type != "tcp" && ((!unauthenticatedTarget && strings.TrimSpace(p.TargetUser) == "") || strings.TrimSpace(p.GatewayUser) == "") { return errors.New("target and gateway usernames are required") } if IsSSH(p.Type) { diff --git a/internal/protocol/httpproxy/http.go b/internal/protocol/httpproxy/http.go index 6a6bd55..3ff7898 100644 --- a/internal/protocol/httpproxy/http.go +++ b/internal/protocol/httpproxy/http.go @@ -52,7 +52,9 @@ func (h *Handler) Test(ctx context.Context, p model.Proxy, dial model.DialFunc) if err != nil { return model.TestResult{}, err } - req.SetBasicAuth(p.TargetUser, p.TargetPassword) + if p.TargetUser != "" { + req.SetBasicAuth(p.TargetUser, p.TargetPassword) + } resp, err := tr.RoundTrip(req) if err != nil { return model.TestResult{}, errors.New("HTTP target connection failed") @@ -156,7 +158,10 @@ func (h *Handler) Serve(ctx context.Context, raw net.Conn, p model.Proxy, dial m pr.Out.Host = targetURL(selected).Host pr.Out.Header.Del("Proxy-Authorization") pr.Out.Header.Del("Forwarded") - pr.Out.SetBasicAuth(selected.TargetUser, selected.TargetPassword) + pr.Out.Header.Del("Authorization") + if selected.TargetUser != "" { + pr.Out.SetBasicAuth(selected.TargetUser, selected.TargetPassword) + } }, ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { http.Error(w, "upstream unavailable", http.StatusBadGateway) }} diff --git a/internal/protocol/httpproxy/no_auth_test.go b/internal/protocol/httpproxy/no_auth_test.go new file mode 100644 index 0000000..2fd5420 --- /dev/null +++ b/internal/protocol/httpproxy/no_auth_test.go @@ -0,0 +1,71 @@ +package httpproxy + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "serviceport/internal/model" +) + +func TestAnonymousHTTPStripsGatewayCredentials(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "" || request.Header.Get("Proxy-Authorization") != "" { + t.Error("gateway credentials leaked to anonymous target") + } + response.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + host, port, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://")) + number, _ := strconv.Atoi(port) + proxy := model.Proxy{Type: "http", TargetHost: host, TargetPort: number, + GatewayAccounts: []model.GatewayAccount{{ID: "client", Username: "client", Password: "client-secret", TargetAccountID: "anonymous"}}, + TargetAccounts: []model.TargetAccount{{ID: "anonymous", AuthMethod: "none", Username: "stale-user", Password: "stale-secret"}}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := New().Test(ctx, model.TargetAccount{AuthMethod: "none"}.Apply(proxy), (&net.Dialer{}).DialContext); err != nil { + t.Fatal(err) + } + for _, password := range []string{"wrong", "client-secret"} { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + finished := make(chan error, 1) + go func() { + connection, err := listener.Accept() + if err != nil { + finished <- err + return + } + finished <- New().Serve(ctx, connection, proxy, (&net.Dialer{}).DialContext, nil, nil) + }() + request, _ := http.NewRequest("GET", "http://"+listener.Addr().String()+"/", nil) + request.Close = true + request.SetBasicAuth("client", password) + request.Header.Set("Proxy-Authorization", "gateway-secret") + response, err := (&http.Client{Timeout: 3 * time.Second}).Do(request) + listener.Close() + if err != nil { + t.Fatal(err) + } + response.Body.Close() + expected := http.StatusOK + if password == "wrong" { + expected = http.StatusUnauthorized + } + if response.StatusCode != expected { + t.Fatalf("unexpected status %d", response.StatusCode) + } + select { + case <-finished: + case <-ctx.Done(): + t.Fatal("gateway did not stop") + } + } +} diff --git a/internal/protocol/mongoproxy/gateway.go b/internal/protocol/mongoproxy/gateway.go index 4d199f1..2ddc33a 100644 --- a/internal/protocol/mongoproxy/gateway.go +++ b/internal/protocol/mongoproxy/gateway.go @@ -112,7 +112,7 @@ func connectUpstream(ctx context.Context, p model.Proxy, dial model.DialFunc, au if db == "" { db = "admin" } - if authenticate { + if authenticate && p.TargetUser != "" { helloRequest = append(helloRequest, bson.E{Key: "saslSupportedMechs", Value: db + "." + p.TargetUser}) } if p.TargetCompression == "zlib" { @@ -143,7 +143,7 @@ func connectUpstream(ctx context.Context, p model.Proxy, dial model.DialFunc, au c = &compressedConn{Conn: c} } } - if !authenticate { + if !authenticate || p.TargetUser == "" { good = true c.SetDeadline(time.Time{}) return &upstream{Conn: c, hello: hello, compression: compression}, nil diff --git a/internal/protocol/mongoproxy/no_auth_test.go b/internal/protocol/mongoproxy/no_auth_test.go new file mode 100644 index 0000000..f4aac24 --- /dev/null +++ b/internal/protocol/mongoproxy/no_auth_test.go @@ -0,0 +1,120 @@ +package mongoproxy + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "serviceport/internal/model" +) + +func TestAnonymousMongoDoesNotSendSCRAM(t *testing.T) { + for _, reject := range []bool{false, true} { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + client, server := net.Pipe() + finished := make(chan struct{}) + go func() { + defer close(finished) + defer server.Close() + server.SetDeadline(time.Now().Add(3 * time.Second)) + for _, command := range []string{"isMaster", "buildInfo"} { + message, err := readMessage(server) + if err != nil { + t.Error(err) + return + } + document, err := decode(message.doc) + if err != nil || len(document) == 0 || document[0].Key != command { + t.Error("unexpected anonymous command") + return + } + if lookup(document, "saslSupportedMechs") != nil { + t.Error("anonymous hello requested auth mechanisms") + } + response := bson.D{{Key: "ok", Value: int32(1)}, {Key: "version", Value: "fixture-mongo"}} + if reject && command == "buildInfo" { + response = bson.D{{Key: "ok", Value: int32(0)}, {Key: "code", Value: int32(13)}, {Key: "codeName", Value: "Unauthorized"}} + } + if err := writeDocument(server, requestID.Add(1), message.id, false, response); err != nil { + t.Error(err) + return + } + } + }() + proxy := model.Proxy{Type: "mongodb", TargetHost: "fixture.invalid", TargetPort: 27017} + result, err := New().Test(ctx, proxy, func(context.Context, string, string) (net.Conn, error) { return client, nil }) + client.Close() + cancel() + <-finished + if (err != nil) != reject { + t.Fatalf("reject=%v error=%v", reject, err) + } + if !reject && result.Version != "fixture-mongo" { + t.Fatal("anonymous metadata not read") + } + } +} + +func TestAnonymousMongoStillAuthenticatesGatewayClients(t *testing.T) { + for _, password := range []string{"wrong", "gateway-secret"} { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + var fixtures sync.WaitGroup + dial := func(context.Context, string, string) (net.Conn, error) { + client, server := net.Pipe() + fixtures.Add(1) + go func() { + defer fixtures.Done() + defer server.Close() + stop := context.AfterFunc(ctx, func() { server.Close() }) + defer stop() + for { + message, err := readMessage(server) + if err != nil { + return + } + document, err := decode(message.doc) + if err != nil || len(document) == 0 { + t.Error("invalid fixture command") + return + } + if document[0].Key == "saslStart" || document[0].Key == "saslContinue" || lookup(document, "saslSupportedMechs") != nil { + t.Error("gateway sent authentication to anonymous target") + return + } + response := bson.D{{Key: "ok", Value: int32(1)}, {Key: "isWritablePrimary", Value: true}, {Key: "minWireVersion", Value: int32(0)}, {Key: "maxWireVersion", Value: int32(17)}} + if err := writeDocument(server, requestID.Add(1), message.id, false, response); err != nil { + return + } + } + }() + return client, nil + } + client, server := net.Pipe() + finished := make(chan error, 1) + proxy := model.Proxy{Type: "mongodb", TargetHost: "fixture.invalid", TargetPort: 27017, GatewayUser: "gateway", GatewayPassword: "gateway-secret", AccessMode: "readwrite"} + go func() { finished <- New().Serve(ctx, server, proxy, dial, nil, nil) }() + connection, err := connect(ctx, model.Proxy{TargetHost: "fixture.invalid", TargetPort: 27018, TargetUser: "gateway", TargetPassword: password}, func(context.Context, string, string) (net.Conn, error) { return client, nil }) + if password == "wrong" { + if err == nil { + t.Error("wrong gateway password accepted") + } + } else if err != nil { + t.Error(err) + } else { + response, err := exchange(connection, bson.D{{Key: "ping", Value: int32(1)}, {Key: "$db", Value: "admin"}}) + if err != nil || !ok(response) { + t.Errorf("anonymous target ping: %v", err) + } + } + if connection != nil { + connection.Close() + } + client.Close() + cancel() + <-finished + fixtures.Wait() + } +} diff --git a/internal/store/portable.go b/internal/store/portable.go index 886a183..bcf512a 100644 --- a/internal/store/portable.go +++ b/internal/store/portable.go @@ -110,6 +110,7 @@ func validateLegacyKey(database *sql.DB, vault *security.Vault) error { {"alert_settings", "SELECT 'alert-settings',config FROM alert_settings"}, {"s3_backup_settings", "SELECT 's3-backup-settings',config FROM s3_backup_settings"}, {"s3_sync_settings", "SELECT 's3-sync-settings',config FROM s3_sync_settings"}, + {"webdav_sync_settings", "SELECT 'webdav-sync-settings',config FROM webdav_sync_settings"}, {"config_templates", "SELECT 'template:'||id,config FROM config_templates"}, {"config_history", "SELECT 'history:'||kind||':'||id||':'||revision,config FROM config_history"}, {"operational_settings", "SELECT 'workspace-scope-catalog',config FROM operational_settings WHERE id='scopes'"}, diff --git a/internal/store/s3_sync.go b/internal/store/s3_sync.go deleted file mode 100644 index e534da2..0000000 --- a/internal/store/s3_sync.go +++ /dev/null @@ -1,166 +0,0 @@ -package store - -import ( - "database/sql" - "encoding/json" - "errors" - "serviceport/internal/backup" - "serviceport/internal/security" - "time" -) - -type S3SyncSettings struct { - backup.S3Config - Automatic bool `json:"automatic"` - IntervalMinutes int `json:"intervalMinutes"` - Passphrase string `json:"passphrase"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (c S3SyncSettings) Validate() error { - if err := c.S3Config.Validate(); err != nil { - return err - } - if c.IntervalMinutes < 1 || c.IntervalMinutes > 10080 { - return errors.New("sync interval must be between 1 and 10080 minutes") - } - if (c.Automatic || c.Passphrase != "") && (len(c.Passphrase) < 12 || len(c.Passphrase) > 1024) { - return errors.New("sync requires a passphrase of 12-1024 bytes") - } - return nil -} - -type S3SyncState struct { - DeviceID string `json:"deviceId"` - LastAttempt string `json:"lastAttempt"` - LastSuccess string `json:"lastSuccess"` - LastBucket string `json:"lastBucket"` - LastKey string `json:"lastKey"` - LastError string `json:"lastError"` - BaseHash string `json:"-"` - Conflict bool `json:"conflict"` - LocalHash string `json:"localHash"` - RemoteHash string `json:"remoteHash"` - Action string `json:"action"` - Running bool `json:"running"` -} - -func (s *Store) initS3Sync() error { - _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS s3_sync_settings(id INTEGER PRIMARY KEY CHECK(id=1),config BLOB NOT NULL); - CREATE TABLE IF NOT EXISTS s3_sync_state( - id INTEGER PRIMARY KEY CHECK(id=1), device_id TEXT NOT NULL, - last_attempt TEXT NOT NULL DEFAULT '', last_success TEXT NOT NULL DEFAULT '', - last_bucket TEXT NOT NULL DEFAULT '', last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', - base_hash TEXT NOT NULL DEFAULT '', conflict INTEGER NOT NULL DEFAULT 0, local_hash TEXT NOT NULL DEFAULT '', remote_hash TEXT NOT NULL DEFAULT '', action TEXT NOT NULL DEFAULT '', - lease_until INTEGER NOT NULL DEFAULT 0, lease_id TEXT NOT NULL DEFAULT '');`) - if err != nil { - return err - } - _, err = s.db.Exec("INSERT INTO s3_sync_state(id,device_id) VALUES(1,?) ON CONFLICT(id) DO NOTHING", security.Token()) - return err -} - -func (s *Store) S3SyncSettings() (S3SyncSettings, error) { - c := S3SyncSettings{S3Config: backup.S3Config{Region: "us-east-1", Prefix: "serviceport/", PathStyle: true}, IntervalMinutes: 5} - var raw []byte - err := s.db.QueryRow("SELECT config FROM s3_sync_settings WHERE id=1").Scan(&raw) - if errors.Is(err, sql.ErrNoRows) { - old, e := s.S3BackupSettings() - if e != nil { - return c, e - } - c.S3Config = old.S3Config - return c, nil - } - if err != nil { - return c, err - } - raw, err = s.vault.Open(raw, "s3-sync-settings") - if err != nil { - return c, err - } - err = json.Unmarshal(raw, &c) - return c, err -} - -func (s *Store) SaveS3SyncSettings(c S3SyncSettings) error { - if err := c.Validate(); err != nil { - return err - } - old, err := s.S3SyncSettings() - if err != nil { - return err - } - changed := old.Endpoint != c.Endpoint || old.Region != c.Region || old.Bucket != c.Bucket || old.Prefix != c.Prefix || old.PathStyle != c.PathStyle - c.UpdatedAt = time.Now().UTC() - - raw, err := json.Marshal(c) - if err != nil { - return err - } - raw, err = s.vault.Seal(raw, "s3-sync-settings") - if err != nil { - return err - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var leaseUntil int64 - if err = tx.QueryRow("SELECT lease_until FROM s3_sync_state WHERE id=1").Scan(&leaseUntil); err != nil { - return err - } - if leaseUntil > time.Now().Unix() { - return errors.New("wait for the current sync before changing settings") - } - _, err = tx.Exec("INSERT INTO s3_sync_settings(id,config) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET config=excluded.config", raw) - if err != nil { - return err - } - if changed { - if _, err = tx.Exec("UPDATE s3_sync_state SET base_hash='',conflict=0,local_hash='',remote_hash='',action='',last_error='',last_success='',last_attempt='',last_bucket='',last_key='' WHERE id=1"); err != nil { - return err - } - } - return tx.Commit() -} - -func (s *Store) S3SyncState() (S3SyncState, error) { - var state S3SyncState - var leaseUntil int64 - err := s.db.QueryRow("SELECT device_id,last_attempt,last_success,last_bucket,last_key,last_error,lease_until,base_hash,conflict,local_hash,remote_hash,action FROM s3_sync_state WHERE id=1").Scan( - &state.DeviceID, &state.LastAttempt, &state.LastSuccess, &state.LastBucket, &state.LastKey, &state.LastError, &leaseUntil, &state.BaseHash, &state.Conflict, &state.LocalHash, &state.RemoteHash, &state.Action) - state.Running = leaseUntil > time.Now().Unix() - return state, err -} - -// ClaimS3Sync also excludes another process using the same configuration DB. -// An abandoned lease expires after five minutes; uploads have a 30 second limit. -func (s *Store) ClaimS3Sync(now time.Time, expectedLastAttempt string) (string, bool, error) { - lease := security.Token() - res, err := s.db.Exec("UPDATE s3_sync_state SET lease_id=?,lease_until=?,last_attempt=?,last_error='' WHERE id=1 AND lease_until<=? AND last_attempt=?", lease, now.Add(5*time.Minute).Unix(), now.UTC().Format(time.RFC3339Nano), now.Unix(), expectedLastAttempt) - if err != nil { - return "", false, err - } - n, err := res.RowsAffected() - return lease, n == 1, err -} - -func (s *Store) FinishS3Sync(lease, bucket, key, message string) error { - var res sql.Result - var err error - if message == "" { - res, err = s.db.Exec("UPDATE s3_sync_state SET lease_until=0,lease_id='',last_error='',last_success=?,last_bucket=?,last_key=? WHERE id=1 AND lease_id=?", time.Now().UTC().Format(time.RFC3339Nano), bucket, key, lease) - } else { - res, err = s.db.Exec("UPDATE s3_sync_state SET lease_until=0,lease_id='',last_error=? WHERE id=1 AND lease_id=?", message, lease) - } - if err != nil { - return err - } - n, err := res.RowsAffected() - if err == nil && n != 1 { - return errors.New("sync lease expired") - } - return err -} diff --git a/internal/store/store.go b/internal/store/store.go index bb5d567..31cc68e 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -103,7 +103,7 @@ func openStore(path string, v *security.Vault, portable bool) (*Store, error) { s.Close() return nil, err } - if err = s.initS3Sync(); err != nil { + if err = s.initWebDAVSync(); err != nil { s.Close() return nil, err } diff --git a/internal/store/sync_bundle.go b/internal/store/sync_bundle.go index 1b7348c..5cf6bfa 100644 --- a/internal/store/sync_bundle.go +++ b/internal/store/sync_bundle.go @@ -158,7 +158,7 @@ func (s *Store) ReplaceSyncBundle(b Bundle, revision, hash string) error { return err } } - _, err = tx.Exec("UPDATE s3_sync_state SET base_hash=?,conflict=0,local_hash='',remote_hash='',action='pulled' WHERE id=1", hash) + _, err = tx.Exec("UPDATE webdav_sync_state SET base_hash=?,conflict=0,local_hash='',remote_hash='',action='pulled' WHERE id=1", hash) if err != nil { return err } @@ -166,10 +166,10 @@ func (s *Store) ReplaceSyncBundle(b Bundle, revision, hash string) error { } func (s *Store) SetSyncBaseline(hash, action string) error { - _, err := s.db.Exec("UPDATE s3_sync_state SET base_hash=?,conflict=0,local_hash='',remote_hash='',action=? WHERE id=1", hash, action) + _, err := s.db.Exec("UPDATE webdav_sync_state SET base_hash=?,conflict=0,local_hash='',remote_hash='',action=? WHERE id=1", hash, action) return err } func (s *Store) SetSyncConflict(local, remote string) error { - _, err := s.db.Exec("UPDATE s3_sync_state SET conflict=1,local_hash=?,remote_hash=?,action='conflict' WHERE id=1", local, remote) + _, err := s.db.Exec("UPDATE webdav_sync_state SET conflict=1,local_hash=?,remote_hash=?,action='conflict' WHERE id=1", local, remote) return err } diff --git a/internal/store/sync_bundle_test.go b/internal/store/sync_bundle_test.go index 980a957..d6d646a 100644 --- a/internal/store/sync_bundle_test.go +++ b/internal/store/sync_bundle_test.go @@ -74,7 +74,7 @@ func TestSyncReplacementRevisionAtomicityAndLocalData(t *testing.T) { if err != nil || len(snapshot.Gateways) != 0 { t.Fatal("replacement did not delete resources") } - state, err := s.S3SyncState() + state, err := s.WebDAVSyncState() if err != nil || state.BaseHash != hash || state.Action != "pulled" { t.Fatal("baseline was not committed with import") } @@ -101,29 +101,30 @@ func TestLegacyBackupDoesNotEnableSyncAndSettingsLease(t *testing.T) { if _, err = s.db.Exec("INSERT INTO s3_backup_settings(id,config) VALUES(1,?)", raw); err != nil { t.Fatal(err) } - c, err := s.S3SyncSettings() - if err != nil || c.Automatic || c.Passphrase != "" || c.SecretAccessKey != "secret" || c.IntervalMinutes != 5 { - t.Fatal("legacy backup auto-enabled sync or lost connection settings") + c, err := s.WebDAVSyncSettings() + if err != nil || c.Automatic || c.Passphrase != "" || c.Username != "" || c.Password != "" || c.Endpoint != "" || c.IntervalMinutes != 5 { + t.Fatal("legacy S3 settings were incorrectly reused for WebDAV") } + c.Endpoint = "https://dav.example.com/root/" c.Passphrase = "new-sync-passphrase" - if err = s.SaveS3SyncSettings(c); err != nil { + if err = s.SaveWebDAVSyncSettings(c); err != nil { t.Fatal(err) } - lease, ok, err := s.ClaimS3Sync(time.Now(), "") + lease, ok, err := s.ClaimWebDAVSync(time.Now(), "") if err != nil || !ok { t.Fatal("lease failed") } - if _, ok, err = s.ClaimS3Sync(time.Now(), ""); err != nil || ok { + if _, ok, err = s.ClaimWebDAVSync(time.Now(), ""); err != nil || ok { t.Fatal("overlapping lease accepted") } c.Prefix = "different" - if err = s.SaveS3SyncSettings(c); err == nil { + if err = s.SaveWebDAVSyncSettings(c); err == nil { t.Fatal("settings changed during active synchronization") } - if err = s.FinishS3Sync(lease, "test-bucket", "key", ""); err != nil { + if err = s.FinishWebDAVSync(lease, "https://dav.example.com/root/", "key", "pushed", ""); err != nil { t.Fatal(err) } - if err = s.SaveS3SyncSettings(c); err != nil { + if err = s.SaveWebDAVSyncSettings(c); err != nil { t.Fatal(err) } } diff --git a/internal/store/webdav_sync.go b/internal/store/webdav_sync.go new file mode 100644 index 0000000..96c9174 --- /dev/null +++ b/internal/store/webdav_sync.go @@ -0,0 +1,161 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "serviceport/internal/backup" + "serviceport/internal/security" + "time" +) + +type WebDAVSyncSettings struct { + backup.WebDAVConfig + Automatic bool `json:"automatic"` + IntervalMinutes int `json:"intervalMinutes"` + Passphrase string `json:"passphrase"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (c WebDAVSyncSettings) Validate() error { + if err := c.WebDAVConfig.Validate(); err != nil { + return err + } + if c.IntervalMinutes < 1 || c.IntervalMinutes > 10080 { + return errors.New("sync interval must be between 1 and 10080 minutes") + } + if (c.Automatic || c.Passphrase != "") && (len(c.Passphrase) < 12 || len(c.Passphrase) > 1024) { + return errors.New("sync requires a passphrase of 12-1024 bytes") + } + return nil +} + +type WebDAVSyncState struct { + DeviceID string `json:"deviceId"` + LastAttempt string `json:"lastAttempt"` + LastSuccess string `json:"lastSuccess"` + LastEndpoint string `json:"lastEndpoint"` + LastKey string `json:"lastKey"` + LastError string `json:"lastError"` + BaseHash string `json:"-"` + Conflict bool `json:"conflict"` + LocalHash string `json:"localHash"` + RemoteHash string `json:"remoteHash"` + Action string `json:"action"` + Running bool `json:"running"` +} + +func (s *Store) initWebDAVSync() error { + _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS webdav_sync_settings(id INTEGER PRIMARY KEY CHECK(id=1),config BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS webdav_sync_state( + id INTEGER PRIMARY KEY CHECK(id=1), device_id TEXT NOT NULL, + last_attempt TEXT NOT NULL DEFAULT '', last_success TEXT NOT NULL DEFAULT '', + last_endpoint TEXT NOT NULL DEFAULT '', last_key TEXT NOT NULL DEFAULT '', last_error TEXT NOT NULL DEFAULT '', + base_hash TEXT NOT NULL DEFAULT '', conflict INTEGER NOT NULL DEFAULT 0, local_hash TEXT NOT NULL DEFAULT '', remote_hash TEXT NOT NULL DEFAULT '', action TEXT NOT NULL DEFAULT '', + lease_until INTEGER NOT NULL DEFAULT 0, lease_id TEXT NOT NULL DEFAULT '');`) + if err != nil { + return err + } + _, err = s.db.Exec("INSERT INTO webdav_sync_state(id,device_id) VALUES(1,?) ON CONFLICT(id) DO NOTHING", security.Token()) + return err +} + +func (s *Store) WebDAVSyncSettings() (WebDAVSyncSettings, error) { + c := WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Prefix: "serviceport/"}, IntervalMinutes: 5} + var raw []byte + err := s.db.QueryRow("SELECT config FROM webdav_sync_settings WHERE id=1").Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return c, nil + } + if err != nil { + return c, err + } + raw, err = s.vault.Open(raw, "webdav-sync-settings") + if err != nil { + return c, err + } + err = json.Unmarshal(raw, &c) + return c, err +} + +func (s *Store) SaveWebDAVSyncSettings(c WebDAVSyncSettings) error { + if err := c.Validate(); err != nil { + return err + } + old, err := s.WebDAVSyncSettings() + if err != nil { + return err + } + changed := old.Endpoint != c.Endpoint || old.Prefix != c.Prefix || old.Username != c.Username + c.UpdatedAt = time.Now().UTC() + + raw, err := json.Marshal(c) + if err != nil { + return err + } + raw, err = s.vault.Seal(raw, "webdav-sync-settings") + if err != nil { + return err + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var leaseUntil int64 + if err = tx.QueryRow("SELECT lease_until FROM webdav_sync_state WHERE id=1").Scan(&leaseUntil); err != nil { + return err + } + if leaseUntil > time.Now().Unix() { + return errors.New("wait for the current sync before changing settings") + } + _, err = tx.Exec("INSERT INTO webdav_sync_settings(id,config) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET config=excluded.config", raw) + if err != nil { + return err + } + if changed { + if _, err = tx.Exec("UPDATE webdav_sync_state SET base_hash='',conflict=0,local_hash='',remote_hash='',action='',last_error='',last_success='',last_attempt='',last_endpoint='',last_key='' WHERE id=1"); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) WebDAVSyncState() (WebDAVSyncState, error) { + var state WebDAVSyncState + var leaseUntil int64 + err := s.db.QueryRow("SELECT device_id,last_attempt,last_success,last_endpoint,last_key,last_error,lease_until,base_hash,conflict,local_hash,remote_hash,action FROM webdav_sync_state WHERE id=1").Scan( + &state.DeviceID, &state.LastAttempt, &state.LastSuccess, &state.LastEndpoint, &state.LastKey, &state.LastError, &leaseUntil, &state.BaseHash, &state.Conflict, &state.LocalHash, &state.RemoteHash, &state.Action) + state.Running = leaseUntil > time.Now().Unix() + return state, err +} + +// ClaimWebDAVSync also excludes another process using the same configuration DB. +// An abandoned lease expires after five minutes; uploads have a 30 second limit. +func (s *Store) ClaimWebDAVSync(now time.Time, expectedLastAttempt string) (string, bool, error) { + lease := security.Token() + res, err := s.db.Exec("UPDATE webdav_sync_state SET lease_id=?,lease_until=?,last_attempt=?,last_error='' WHERE id=1 AND lease_until<=? AND last_attempt=?", lease, now.Add(5*time.Minute).Unix(), now.UTC().Format(time.RFC3339Nano), now.Unix(), expectedLastAttempt) + if err != nil { + return "", false, err + } + n, err := res.RowsAffected() + return lease, n == 1, err +} + +func (s *Store) FinishWebDAVSync(lease, endpoint, key, action, message string) error { + var res sql.Result + var err error + if message == "" { + res, err = s.db.Exec("UPDATE webdav_sync_state SET lease_until=0,lease_id='',last_error='',last_success=?,last_endpoint=?,last_key=?,action=CASE WHEN ?='backed_up' THEN ? ELSE action END WHERE id=1 AND lease_id=?", time.Now().UTC().Format(time.RFC3339Nano), endpoint, key, action, action, lease) + } else { + res, err = s.db.Exec("UPDATE webdav_sync_state SET lease_until=0,lease_id='',last_error=? WHERE id=1 AND lease_id=?", message, lease) + } + if err != nil { + return err + } + n, err := res.RowsAffected() + if err == nil && n != 1 { + return errors.New("sync lease expired") + } + return err +} diff --git a/internal/syncjob/engine.go b/internal/syncjob/engine.go index df4a76d..7dad267 100644 --- a/internal/syncjob/engine.go +++ b/internal/syncjob/engine.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" "serviceport/internal/backup" + "serviceport/internal/security" "serviceport/internal/store" "strings" "sync" @@ -15,14 +16,14 @@ import ( var ( ErrBusy = errors.New("本机已有同步任务正在运行") - ErrSettings = errors.New("请先保存有效的 S3 同步设置") + ErrSettings = errors.New("请先保存有效的 WebDAV 同步设置") ErrPassphrase = errors.New("同步口令必须为 12–1024 字节") ) type Result struct { - Bucket string `json:"bucket"` - Key string `json:"key"` - Action string `json:"action"` + Endpoint string `json:"endpoint"` + Key string `json:"key"` + Action string `json:"action"` } type Engine struct { @@ -68,7 +69,7 @@ func (e *Engine) Close() { e.mu.Unlock() } -func NextRun(c store.S3SyncSettings, state store.S3SyncState) time.Time { +func NextRun(c store.WebDAVSyncSettings, state store.WebDAVSyncState) time.Time { if !c.Automatic { return time.Time{} } @@ -92,6 +93,7 @@ var ErrConflict = errors.New("两端配置均有变化,请选择保留本机 var ErrRunning = errors.New("远端配置等待应用,请先停止本机全部网关后再次同步") type Request struct { + BackupOnly bool `json:"-"` Passphrase string `json:"passphrase"` Resolution string `json:"resolution"` LocalHash string `json:"localHash"` @@ -110,11 +112,11 @@ func (e *Engine) run(parent context.Context, source string, request Request, sch unlock := e.guard() defer unlock() } - c, err := e.store.S3SyncSettings() + c, err := e.store.WebDAVSyncSettings() if err != nil { return result, ErrSettings } - state, err := e.store.S3SyncState() + state, err := e.store.WebDAVSyncState() if err != nil { return result, errors.New("无法读取同步状态") } @@ -134,7 +136,7 @@ func (e *Engine) run(parent context.Context, source string, request Request, sch if request.Resolution != "" && request.Resolution != "local" && request.Resolution != "remote" { return result, errors.New("无效的同步冲突选择") } - lease, ok, err := e.store.ClaimS3Sync(now, state.LastAttempt) + lease, ok, err := e.store.ClaimWebDAVSync(now, state.LastAttempt) if err != nil { return result, err } @@ -146,19 +148,19 @@ func (e *Engine) run(parent context.Context, source string, request Request, sch if err != nil { message = err.Error() } - if finish := e.store.FinishS3Sync(lease, result.Bucket, result.Key, message); finish != nil && err == nil { + if finish := e.store.FinishWebDAVSync(lease, result.Endpoint, result.Key, result.Action, message); finish != nil && err == nil { err = errors.New("同步已执行,但本机状态保存失败,请重新同步") } if err != nil { - e.store.Event("", source, "s3_sync_failed", "") + e.store.Event("", source, "webdav_sync_failed", "") } else if result.Action != "unchanged" { - e.store.Event("", source, "s3_sync_"+result.Action, "") + e.store.Event("", source, "webdav_sync_"+result.Action, "") } }() ctx, cancel := context.WithTimeout(parent, 60*time.Second) stop := context.AfterFunc(e.ctx, cancel) defer func() { stop(); cancel() }() - result.Bucket = c.Bucket + result.Endpoint = c.Endpoint result.Key = strings.TrimRight(c.Prefix, "/") if result.Key != "" { result.Key += "/" @@ -169,7 +171,26 @@ func (e *Engine) run(parent context.Context, source string, request Request, sch return result, errors.New("无法读取或校验本机同步配置") } lh := store.SyncHash(local) - raw, etag, err := backup.Exchange(ctx, c.S3Config, http.MethodGet, result.Key, nil, "") + if request.BackupOnly { + if request.Resolution != "" { + return result, errors.New("独立备份不接受冲突覆盖选项") + } + envelope, sealErr := store.SealSyncBundle(local, pass) + if sealErr != nil { + return result, errors.New("无法加密本机备份") + } + payload, marshalErr := json.Marshal(envelope) + if marshalErr != nil { + return result, errors.New("无法编码本机备份") + } + result.Key = strings.TrimSuffix(result.Key, "sync/configuration.json") + "backups/" + now.UTC().Format("20060102T150405.000000000Z") + "-" + security.Token()[:16] + ".json" + if _, _, err = backup.WebDAVExchange(ctx, c.WebDAVConfig, http.MethodPut, result.Key, payload, ""); err != nil { + return result, err + } + result.Action = "backed_up" + return result, nil + } + raw, etag, err := backup.WebDAVExchange(ctx, c.WebDAVConfig, http.MethodGet, result.Key, nil, "") missing := errors.Is(err, backup.ErrNotFound) if err != nil && !missing { return result, err @@ -230,7 +251,7 @@ func (e *Engine) run(parent context.Context, source string, request Request, sch if err != nil { return result, err } - _, _, err = backup.Exchange(ctx, c.S3Config, http.MethodPut, result.Key, raw, etag) + _, _, err = backup.WebDAVExchange(ctx, c.WebDAVConfig, http.MethodPut, result.Key, raw, etag) if errors.Is(err, backup.ErrObjectExists) { return result, errors.New("远端配置刚被其他设备修改,本次未覆盖,请重新同步") } diff --git a/internal/syncjob/engine_test.go b/internal/syncjob/engine_test.go index 16f246c..0e821f4 100644 --- a/internal/syncjob/engine_test.go +++ b/internal/syncjob/engine_test.go @@ -14,13 +14,12 @@ import ( "serviceport/internal/model" "serviceport/internal/security" "serviceport/internal/store" - "strings" "sync" "testing" "time" ) -// An in-process S3 fixture, not a live AWS/MinIO service. +// An in-process WebDAV fixture, not a live service. type objectFixture struct { mu sync.Mutex raw []byte @@ -32,11 +31,16 @@ type objectFixture struct { func (o *objectFixture) serve(w http.ResponseWriter, r *http.Request) { o.mu.Lock() defer o.mu.Unlock() - if r.URL.Path != "/test-bucket/team/sync/configuration.json" { + if r.Method == "MKCOL" { + w.WriteHeader(201) + return + } + if r.URL.Path != "/dav/team/sync/configuration.json" { http.Error(w, "path", 400) return } - if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 ") { + user, password, _ := r.BasicAuth() + if user != "test-access" || password != "test-secret" { http.Error(w, "signature", 403) return } @@ -81,8 +85,8 @@ func testDevice(t *testing.T, endpoint string, key byte) (*store.Store, *Engine) t.Fatal(err) } t.Cleanup(func() { s.Close() }) - c := store.S3SyncSettings{S3Config: backup.S3Config{Endpoint: endpoint, Region: "us-east-1", Bucket: "test-bucket", Prefix: "team/", PathStyle: true, AccessKeyID: "test-access", SecretAccessKey: "test-secret"}, IntervalMinutes: 5, Passphrase: "same-sync-passphrase"} - if err = s.SaveS3SyncSettings(c); err != nil { + c := store.WebDAVSyncSettings{WebDAVConfig: backup.WebDAVConfig{Endpoint: endpoint + "/dav/", Prefix: "team/", Username: "test-access", Password: "test-secret"}, IntervalMinutes: 5, Passphrase: "same-sync-passphrase"} + if err = s.SaveWebDAVSyncSettings(c); err != nil { t.Fatal(err) } e := New(s, nil, func() bool { return true }) @@ -169,7 +173,7 @@ func TestTwoDeviceSyncConflictsAndDeletion(t *testing.T) { runSync(t, eb, "unchanged") for _, secret := range []string{"gateway-private-password", "target-private-password", "same-sync-passphrase", "asset-private-content", "proxy-private-password"} { if bytes.Contains(object.raw, []byte(secret)) { - t.Fatal("S3 object contains plaintext secret") + t.Fatal("WebDAV file contains plaintext secret") } } renameGateway(t, b, "from-b") @@ -183,7 +187,7 @@ func TestTwoDeviceSyncConflictsAndDeletion(t *testing.T) { t.Fatalf("expected conflict: %v", err) } assertGateway(t, b, "conflicting-b") - state, err := b.S3SyncState() + state, err := b.WebDAVSyncState() if err != nil || !state.Conflict { t.Fatal("missing conflict state") } @@ -193,7 +197,7 @@ func TestTwoDeviceSyncConflictsAndDeletion(t *testing.T) { if _, err = eb.Run(context.Background(), "test", choice); !errors.Is(err, ErrConflict) { t.Fatal("stale conflict choice was accepted") } - state, _ = b.S3SyncState() + state, _ = b.WebDAVSyncState() choice.LocalHash = state.LocalHash choice.RemoteHash = state.RemoteHash eb.canApply = func() bool { return false } @@ -236,7 +240,7 @@ func TestSyncFirstJoinWrongPasswordConditionalWriteAndSchedule(t *testing.T) { if _, err := eb.Run(context.Background(), "test", Request{}); !errors.Is(err, ErrConflict) { t.Fatal("first join overwrote data") } - state, _ := b.S3SyncState() + state, _ := b.WebDAVSyncState() choice := Request{Resolution: "local", LocalHash: state.LocalHash, RemoteHash: state.RemoteHash} object.rejectNext = true if _, err := eb.Run(context.Background(), "test", choice); err == nil { @@ -248,14 +252,14 @@ func TestSyncFirstJoinWrongPasswordConditionalWriteAndSchedule(t *testing.T) { } runSync(t, ea, "pulled") assertGateway(t, a, "b") - c, _ := a.S3SyncSettings() + c, _ := a.WebDAVSyncSettings() c.Automatic = true c.IntervalMinutes = 1 - if err := a.SaveS3SyncSettings(c); err != nil { + if err := a.SaveWebDAVSyncSettings(c); err != nil { t.Fatal(err) } - c, _ = a.S3SyncSettings() - state, _ = a.S3SyncState() + c, _ = a.WebDAVSyncSettings() + state, _ = a.WebDAVSyncState() due := NextRun(c, state) before := object.gets ea.Check(context.Background(), due.Add(-time.Second)) @@ -267,10 +271,10 @@ func TestSyncFirstJoinWrongPasswordConditionalWriteAndSchedule(t *testing.T) { runSync(t, eb, "pulled") assertGateway(t, b, "scheduled") c.Prefix = "different/" - if err := a.SaveS3SyncSettings(c); err != nil { + if err := a.SaveWebDAVSyncSettings(c); err != nil { t.Fatal(err) } - state, _ = a.S3SyncState() + state, _ = a.WebDAVSyncState() if state.BaseHash != "" || state.Conflict { t.Fatal("changed namespace retained baseline") } diff --git a/internal/syncjob/scope_catalog_test.go b/internal/syncjob/scope_catalog_test.go index 934f543..458483d 100644 --- a/internal/syncjob/scope_catalog_test.go +++ b/internal/syncjob/scope_catalog_test.go @@ -46,7 +46,7 @@ func TestCatalogOnlyDeviceSyncAndFirstSyncConflict(t *testing.T) { if !reflect.DeepEqual(before.ScopeCatalog, after.ScopeCatalog) { t.Fatal("conflict modified local choices") } - state, err := b.S3SyncState() + state, err := b.WebDAVSyncState() if err != nil { t.Fatal(err) } diff --git a/internal/syncjob/webdav_backup_test.go b/internal/syncjob/webdav_backup_test.go new file mode 100644 index 0000000..3b0b4e5 --- /dev/null +++ b/internal/syncjob/webdav_backup_test.go @@ -0,0 +1,69 @@ +package syncjob + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "serviceport/internal/store" +) + +func TestWebDAVIndependentEncryptedBackupsDoNotChangeSyncBaseline(t *testing.T) { + files := map[string][]byte{} + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if request.Method == "MKCOL" { + response.WriteHeader(201) + return + } + if request.Method != "PUT" || !strings.HasPrefix(request.URL.Path, "/dav/team/backups/") || request.Header.Get("If-None-Match") != "*" { + t.Error("backup accessed shared sync file") + response.WriteHeader(400) + return + } + if _, exists := files[request.URL.Path]; exists { + response.WriteHeader(412) + return + } + files[request.URL.Path], _ = io.ReadAll(request.Body) + response.WriteHeader(201) + })) + defer server.Close() + device, engine := testDevice(t, server.URL, 9) + seedGateway(t, device, "backup-only") + if err := device.SetSyncBaseline("unchanged-baseline", "pushed"); err != nil { + t.Fatal(err) + } + if err := device.SetSyncConflict("local-conflict", "remote-conflict"); err != nil { + t.Fatal(err) + } + for index := 0; index < 2; index++ { + result, err := engine.Run(context.Background(), "fixture", Request{BackupOnly: true}) + if err != nil || result.Action != "backed_up" { + t.Fatalf("backup failed: %v", err) + } + } + if len(files) != 2 { + t.Fatal("backups were not independent") + } + for _, raw := range files { + if strings.Contains(string(raw), "target-private-password") { + t.Fatal("plaintext backup") + } + var envelope store.EncryptedBackup + if json.Unmarshal(raw, &envelope) != nil { + t.Fatal("not an importable backup") + } + bundle, err := store.OpenBackup(envelope, "same-sync-passphrase") + if err != nil || len(bundle.Targets) != 1 || bundle.Targets[0].Accounts[0].Password != "target-private-password" { + t.Fatalf("backup cannot restore credentials: %v", err) + } + } + state, err := device.WebDAVSyncState() + if err != nil || state.BaseHash != "unchanged-baseline" || !state.Conflict || state.Action != "backed_up" { + t.Fatalf("backup modified sync baseline: %+v %v", state, err) + } +} diff --git a/web/src/App.vue b/web/src/App.vue index 9f6ad7d..d66a313 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -32,7 +32,7 @@ import { targetDraftPayload } from './domain/targetDraft'; import SourceAllowlist from './components/SourceAllowlist.vue'; import OpsPanel from './components/OpsPanel.vue'; import ConfigurationManager from './components/ConfigurationManager.vue'; -import S3SyncManager from './components/S3SyncManager.vue'; +import WebDAVSyncManager from './components/WebDAVSyncManager.vue'; import NavicatDecrypt from './components/NavicatDecrypt.vue'; import SQLiteBrowser from './components/SQLiteBrowser.vue'; import GatewayTargetSwitch from './components/GatewayTargetSwitch.vue'; @@ -112,7 +112,7 @@ const menuItems = computed(() => [ {id:'connections',label:'拖拽连线',icon:'link'}, {id:'operations',label:'监控运维',icon:'settings',keywords:'健康 告警 Token'}, {id:'configuration',label:'配置管理',icon:'files',keywords:'备份 导出 导入 模板 历史'}, - {id:'sync',label:'多端同步',icon:'cloud',keywords:'S3 MinIO'}, + {id:'sync',label:'多端同步',icon:'cloud',keywords:'WebDAV 同步备份'}, {id:'decrypt',label:'密码解密',icon:'key',keywords:'Navicat AES'}, {id:'sqlite',label:'SQLite 文件',icon:'file',keywords:'数据库 本地 SQL 查询'}, ]); @@ -1031,7 +1031,7 @@ onUnmounted(() => { {{ page === 'sqlite' ? '连接本地 SQLite 文件,查看表结构并查询数据。' : page === 'configuration' ? '备份与导入配置、管理模板,以及查看和恢复配置历史。' : - page === 'sync' ? '通过 S3 在多台设备间同步配置,查看同步状态并处理冲突。' : + page === 'sync' ? '通过 WebDAV 同步和加密备份配置,查看状态并处理冲突。' : page === 'operations' ? '查看网关连接、目标健康状态与告警,管理运维访问令牌。' : page === 'decrypt' ? '选择预设或配置算法、Key 与 IV,解密密码和文本。' : page === "commands" ? "查看 SQL、MongoDB、SSH 与 SFTP 请求。内容加密保存,保留 7 天。" : page === "events" ? "查看连接生命周期、安全操作和只读拦截记录。" @@ -1054,7 +1054,7 @@ onUnmounted(() => { - +
diff --git a/web/src/components/ConnectionURLImport.vue b/web/src/components/ConnectionURLImport.vue index 4842756..526e20a 100644 --- a/web/src/components/ConnectionURLImport.vue +++ b/web/src/components/ConnectionURLImport.vue @@ -24,7 +24,7 @@ function apply() { account = {id:crypto.randomUUID(),authMethod:'password'}; accounts.push(account); } - Object.assign(account, credential, {name:account.name || credential.username}); + Object.assign(account, credential, {name:account.name || credential.username, authMethod:'password', clearPassword:false}); if (['ssh','sftp'].includes(form.value.type)) Object.assign(account, {authMethod:'password',privateKey:'',privateKeyAssetId:'',certificateAssetId:'',certificate:'',passphrase:''}); } Object.assign(form.value, {...connection,connectionOptions:{...form.value.connectionOptions,...connection.connectionOptions},accounts}); diff --git a/web/src/components/MongoConnectionImport.vue b/web/src/components/MongoConnectionImport.vue index 17e8d24..9c4c94d 100644 --- a/web/src/components/MongoConnectionImport.vue +++ b/web/src/components/MongoConnectionImport.vue @@ -19,7 +19,7 @@ function apply() { if (accounts.length >= 32) throw new Error('最多支持 32 个账号,请选择已有账号'); account = {id: crypto.randomUUID(), authMethod: 'password'}; accounts.push(account); } - Object.assign(account, credential, {name: account.name || credential.username}); + Object.assign(account, credential, {name: account.name || credential.username, authMethod: 'password', clearPassword: false}); } emit('update:modelValue', {...props.modelValue, ...connection, accounts}); uri.value = ''; diff --git a/web/src/components/S3SyncManager.vue b/web/src/components/S3SyncManager.vue deleted file mode 100644 index ecba3e2..0000000 --- a/web/src/components/S3SyncManager.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/web/src/components/TargetAccounts.vue b/web/src/components/TargetAccounts.vue index 741e688..66e06f7 100644 --- a/web/src/components/TargetAccounts.vue +++ b/web/src/components/TargetAccounts.vue @@ -1,6 +1,7 @@ + + + + diff --git a/web/src/composables/useS3Sync.ts b/web/src/composables/useWebDAVSync.ts similarity index 61% rename from web/src/composables/useS3Sync.ts rename to web/src/composables/useWebDAVSync.ts index 0a0a081..1f8bb66 100644 --- a/web/src/composables/useS3Sync.ts +++ b/web/src/composables/useWebDAVSync.ts @@ -2,20 +2,19 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'; type API = (path: string, method?: string, body?: unknown) => Promise; type Settings = { - endpoint: string; region: string; bucket: string; prefix: string; pathStyle: boolean; - accessKeyId: string; automatic: boolean; intervalMinutes: number; - hasSecretAccessKey: boolean; hasSessionToken: boolean; hasPassphrase: boolean; + endpoint: string; prefix: string; username: string; + automatic: boolean; intervalMinutes: number; + hasPassword: boolean; hasPassphrase: boolean; }; type Status = { - state: { deviceId: string; lastAttempt: string; lastSuccess: string; lastBucket: string; + state: { deviceId: string; lastAttempt: string; lastSuccess: string; lastEndpoint: string; lastKey: string; lastError: string; running: boolean; conflict: boolean; localHash: string; remoteHash: string; action: string }; nextRun: string; }; -export function useS3Sync(api: API, report: (error: unknown) => void, success: (message: string) => void, changed: () => void) { - const form = reactive({ endpoint: '', region: 'us-east-1', bucket: '', prefix: 'serviceport/', - pathStyle: true, accessKeyId: '', secretAccessKey: '', sessionToken: '', clearSessionToken: false, +export function useWebDAVSync(api: API, report: (error: unknown) => void, success: (message: string) => void, changed: () => void) { + const form = reactive({ endpoint: '', prefix: 'serviceport/', username: '', password: '', clearPassword: false, automatic: false, intervalMinutes: 5, passphrase: '' }); const saved = ref(); const initial = ref(''); @@ -29,16 +28,16 @@ export function useS3Sync(api: API, report: (error: unknown) => void, success: ( function accept(value: Settings) { saved.value = value; - for (const field of ['endpoint', 'region', 'bucket', 'prefix', 'accessKeyId'] as const) form[field] = value[field]; - form.pathStyle = value.pathStyle; form.automatic = value.automatic; form.intervalMinutes = value.intervalMinutes; - form.secretAccessKey = form.sessionToken = form.passphrase = ''; - form.clearSessionToken = false; initial.value = JSON.stringify(form); + for (const field of ['endpoint', 'prefix', 'username'] as const) form[field] = value[field]; + form.automatic = value.automatic; form.intervalMinutes = value.intervalMinutes; + form.password = form.passphrase = ''; + form.clearPassword = false; initial.value = JSON.stringify(form); } async function refreshStatus() { if (polling || !alive) return; polling = true; try { - const value: Status = await api('configuration/s3/status'); + const value: Status = await api('configuration/webdav/status'); if (alive) { if (value.state.lastSuccess !== status.value?.state.lastSuccess && value.state.action === 'pulled') changed(); status.value = value; statusError.value = ''; @@ -48,7 +47,7 @@ export function useS3Sync(api: API, report: (error: unknown) => void, success: ( } async function load() { loading.value = true; loadError.value = ''; - try { const value = await api('configuration/s3/settings'); if (alive) accept(value); await refreshStatus(); } + try { const value = await api('configuration/webdav/settings'); if (alive) accept(value); await refreshStatus(); } catch (error) { if (alive) loadError.value = String(error); } finally { if (alive) loading.value = false; } } @@ -57,37 +56,35 @@ export function useS3Sync(api: API, report: (error: unknown) => void, success: ( } async function save() { if (busy.value) return; - if (!form.bucket || !form.region || !form.accessKeyId || (!form.secretAccessKey && !saved.value?.hasSecretAccessKey)) { - report('请填写 Region、Bucket、Access Key ID 和 Secret Access Key'); return; - } + if (!/^https?:\/\//.test(form.endpoint)) { report('请填写 WebDAV HTTP(S) 目录地址'); return; } if ((form.passphrase && !validPassphrase(form.passphrase)) || (form.automatic && !form.passphrase && !saved.value?.hasPassphrase)) { report('同步口令需要 12–1024 字节;自动同步必须保存口令'); return; } saving.value = true; try { - const value = await api('configuration/s3/settings', 'PUT', { ...form }); - if (alive) { accept(value); success('S3 同步设置已保存'); } await refreshStatus(); + const value = await api('configuration/webdav/settings', 'PUT', { ...form }); + if (alive) { accept(value); success('WebDAV 同步设置已保存'); } await refreshStatus(); } catch (error) { if (alive) report(error); } finally { if (alive) saving.value = false; } } - async function sync(resolution = '') { + async function sync(resolution = '', backupOnly = false) { if (busy.value || dirty.value || status.value?.state.running) return; if ((manualPassphrase.value && !validPassphrase(manualPassphrase.value)) || (!manualPassphrase.value && !saved.value?.hasPassphrase)) { report('请填写 12–1024 字节的同步口令,所有设备需要使用相同口令'); return; } syncing.value = true; try { - const result = await api('configuration/s3/sync', 'POST', { passphrase: manualPassphrase.value, resolution, + const result = await api(backupOnly ? 'configuration/webdav/backup' : 'configuration/webdav/sync', 'POST', { passphrase: manualPassphrase.value, resolution, localHash: status.value?.state.localHash, remoteHash: status.value?.state.remoteHash }); if (alive) { manualPassphrase.value = ''; - success(result.action === 'pulled' ? '已拉取并应用远端配置,网关保持停止' : result.action === 'pushed' ? '本机配置已同步到 S3' : '本机与远端配置一致'); + success(result.action === 'backed_up' ? '本机加密备份已上传到 WebDAV' : result.action === 'pulled' ? '已拉取并应用远端配置,网关保持停止' : result.action === 'pushed' ? '本机配置已同步到 WebDAV' : '本机与远端配置一致'); if (result.action === 'pulled') changed(); } } catch (error) { if (alive) report(error); } finally { await refreshStatus(); if (alive) syncing.value = false; } } onMounted(() => { void load(); timer = setInterval(() => { void refreshStatus(); }, 15000); }); - onUnmounted(() => { alive = false; if (timer) clearInterval(timer); form.secretAccessKey = form.sessionToken = form.passphrase = manualPassphrase.value = ''; }); + onUnmounted(() => { alive = false; if (timer) clearInterval(timer); form.password = form.passphrase = manualPassphrase.value = ''; }); return { form, saved, status, loading, saving, syncing, busy, dirty, loadError, statusError, manualPassphrase, load, save, sync, refreshStatus }; } diff --git a/web/src/domain/formValidation.ts b/web/src/domain/formValidation.ts index eafadb4..f28c8b1 100644 --- a/web/src/domain/formValidation.ts +++ b/web/src/domain/formValidation.ts @@ -1,5 +1,6 @@ import { requestErrorDetails } from './requestError'; +import { supportsNoAuth } from './targetAuth'; export type FieldIssue = { path: string; label: string; message: string; tab: string; accountId?: string }; type Options = { assets?: any[]; outbounds?: any[]; secretStatus?: Record; editing?: boolean; targetAccounts?: any[]; targets?: any[] }; const bytes = (v: string) => new TextEncoder().encode(v || '').length; @@ -72,7 +73,8 @@ export function validateTarget(draft: any, options: Options = {}): FieldIssue[] if (!/^[a-zA-Z0-9_-]{1,64}$/.test(a.id || '') || ids.has(a.id)) add(`${prefix}.id`,'账号 ID','账号 ID 必须为 1–64 位字母、数字、下划线或连字符,且不能重复','accounts'); ids.add(a.id); if (!a.name?.trim() || bytes(a.name) > 100) add(`${prefix}.name`,'账号名称','请填写账号名称,最多 100 个 UTF-8 字节','accounts'); - if (!a.username?.trim()) add(`${prefix}.username`,'登录用户名','请填写目标登录用户名','accounts'); + if (a.authMethod === 'none' && !supportsNoAuth(draft.type)) add(`${prefix}.authMethod`,'认证方式','此协议不支持无认证,请填写用户名并选择密码或私钥','accounts'); + if (a.authMethod !== 'none' && !a.username?.trim()) add(`${prefix}.username`,'登录用户名','请填写目标登录用户名','accounts'); if (['ssh','sftp'].includes(draft.type) && a.authMethod === 'privatekey' && !a.privateKeyAssetId && !a.privateKey && !options.secretStatus?.[`account:${a.id}:privateKey`]) add(`${prefix}.privateKeyAssetId`,'账号私钥资产','请选择私钥资产','accounts'); } return issues; @@ -149,6 +151,7 @@ export function translateAPIError(error: unknown): string { [/project must contain/i,'项目名称最多 100 个 UTF-8 字节,不能包含首尾空格、换行或空字符'], [/environment must contain/i,'环境名称最多 100 个 UTF-8 字节,不能包含首尾空格、换行或空字符'], [/target and gateway usernames are required/i,'请填写目标和网关用户名'], + [/target protocol does not support unauthenticated accounts/i,'此目标协议不支持无认证账号'], [/gateway (account )?password is required/i,'请填写网关密码'], [/invalid (account )?source IP\/CIDR/i,'来源白名单包含无效的 IP 或 CIDR'], [/invalid or duplicate gateway username/i,'网关用户名为空、重复或格式无效'], diff --git a/web/src/domain/targetAuth.ts b/web/src/domain/targetAuth.ts new file mode 100644 index 0000000..55b2577 --- /dev/null +++ b/web/src/domain/targetAuth.ts @@ -0,0 +1,12 @@ +export function supportsNoAuth(protocol: string) { + return ['mongodb', 'redis', 'http', 'https'].includes(protocol); +} + +export function changeTargetAuth(account: any) { + if (account.authMethod === 'none') { + for (const key of ['username', 'password', 'privateKey', 'privateKeyAssetId', 'certificate', 'certificateAssetId', 'passphrase']) account[key] = ''; + account.clearPassword = false; + account.clearPassphrase = false; + if (!account.name) account.name = '无认证账号'; + } +} diff --git a/web/src/domain/targetDraft.ts b/web/src/domain/targetDraft.ts index f2b51c6..42066a3 100644 --- a/web/src/domain/targetDraft.ts +++ b/web/src/domain/targetDraft.ts @@ -16,6 +16,11 @@ export function targetDraftPayload(draft:any) { account.certificateAssetId = ''; } for (const key of ['password','privateKey','passphrase']) if (!account[key]) delete account[key]; + if (account.clearPassword) account.password = ''; + if (account.authMethod === 'none') { + for (const key of ['username','password','privateKey','privateKeyAssetId','certificate','certificateAssetId','passphrase']) account[key] = ''; + } + delete account.clearPassword; if (account.clearPassphrase) account.passphrase = ''; delete account.clearPassphrase; return account; diff --git a/web/tests/menu-search.spec.ts b/web/tests/menu-search.spec.ts index 4b49f1a..d356ab0 100644 --- a/web/tests/menu-search.spec.ts +++ b/web/tests/menu-search.spec.ts @@ -4,7 +4,7 @@ test('菜单可按名称及关键词搜索,清空恢复全部菜单并支持 await page.goto('/'); const nav = page.getByRole('navigation'); const search = page.getByRole('textbox', { name: '搜索菜单', exact: true }); - await search.fill('S3'); + await search.fill('WebDAV'); await expect(nav.getByRole('button')).toHaveCount(1); await nav.getByRole('button', { name: '多端同步', exact: true }).click(); await expect(page.getByRole('heading', { name: '多端同步', exact: true })).toBeVisible(); @@ -35,6 +35,6 @@ test('个人菜单搜索不再包含用户管理', async ({ page }) => { await search.fill('管理用户'); await expect(page.getByRole('navigation').getByRole('button')).toHaveCount(0); await expect(page.getByRole('navigation').getByRole('status')).toHaveText('没有匹配的菜单'); - await search.fill('S3'); + await search.fill('WebDAV'); await expect(page.getByRole('navigation').getByRole('button', { name: '多端同步', exact: true })).toBeVisible(); }); diff --git a/web/tests/s3-sync.spec.ts b/web/tests/s3-sync.spec.ts deleted file mode 100644 index e05570b..0000000 --- a/web/tests/s3-sync.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('S3 多端同步设置、冲突处理与自适应布局', async ({ page }) => { - const settings = { endpoint: '', region: 'us-east-1', bucket: '', prefix: 'serviceport/', pathStyle: true, - accessKeyId: '', automatic: false, intervalMinutes: 5, hasSecretAccessKey: false, hasSessionToken: false, hasPassphrase: false }; - const state = { deviceId: 'test-device', lastAttempt: '', lastSuccess: '', lastBucket: '', lastKey: '', lastError: '', - running: false, conflict: false, localHash: '', remoteHash: '', action: '' }; - let lastSave: any, lastSync: any; - await page.route('**/api/v1/configuration/s3/settings', async route => { - if (route.request().method() === 'PUT') { - lastSave = route.request().postDataJSON(); - Object.assign(settings, { bucket: lastSave.bucket, accessKeyId: lastSave.accessKeyId, automatic: lastSave.automatic, - hasSecretAccessKey: true, hasPassphrase: true }); - } - await route.fulfill({ json: settings }); - }); - await page.route('**/api/v1/configuration/s3/status', route => route.fulfill({ json: { state, nextRun: '0001-01-01T00:00:00Z' } })); - await page.route('**/api/v1/configuration/s3/sync', async route => { - lastSync = route.request().postDataJSON(); - if (!lastSync.resolution) { - Object.assign(state, { conflict: true, localHash: 'local-revision', remoteHash: 'remote-revision', action: 'conflict', lastError: '两端配置均有变化' }); - await route.fulfill({ status: 409, json: { error: '两端配置均有变化' } }); - } else { - Object.assign(state, { conflict: false, action: 'pulled', lastSuccess: new Date().toISOString(), lastError: '' }); - await route.fulfill({ json: { action: 'pulled' } }); - } - }); - await page.goto('/'); - await page.getByRole('navigation').getByRole('button', { name: '监控运维', exact: true }).click(); - const panel = page.getByRole('region', { name: 'S3 多端同步', exact: true }); - await expect(panel).toHaveCount(0); - await expect(page.getByRole('heading', { name: '配置备份', exact: true })).toHaveCount(0); - await expect(page.getByRole('region', { name: '配置模板管理', exact: true })).toHaveCount(0); - await page.getByRole('navigation').getByRole('button', { name: '配置管理', exact: true }).click(); - await expect(page.getByRole('heading', { name: '配置备份', exact: true })).toBeVisible(); - await expect(panel).toHaveCount(0); - await page.getByRole('navigation').getByRole('button', { name: '多端同步', exact: true }).click(); - await expect(page.getByRole('heading', { name: '配置备份', exact: true })).toHaveCount(0); - await expect(panel).toBeVisible(); - await expect(panel).toContainText('监听地址、端口、账号映射'); - await panel.getByRole('textbox', { name: 'S3 Bucket', exact: true }).fill('shared-config'); - await panel.getByRole('textbox', { name: 'S3 Access Key ID', exact: true }).fill('access-id'); - await panel.getByRole('textbox', { name: 'S3 Secret Access Key', exact: true }).fill('secret-access-key'); - await panel.getByRole('textbox', { name: 'S3 同步口令', exact: true }).fill('shared-sync-passphrase'); - await panel.locator('.el-switch').click(); - await expect(panel.getByRole('spinbutton', { name: '自动同步间隔' })).toHaveValue('5'); - await panel.getByRole('button', { name: '保存 S3 设置', exact: true }).click(); - await expect(panel.getByRole('textbox', { name: 'S3 同步口令', exact: true })).toHaveValue(''); - expect(lastSave.automatic).toBe(true); - expect(lastSave.passphrase).toBe('shared-sync-passphrase'); - await panel.getByRole('button', { name: '立即同步', exact: true }).click(); - await expect(panel.getByText('同步冲突:本机与远端配置不同', { exact: true })).toBeVisible(); - for (const viewport of [{ width: 320, height: 568 }, { width: 390, height: 844 }, { width: 768, height: 640 }, { width: 1920, height: 1080 }]) { - await page.setViewportSize(viewport); - expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); - } - await panel.getByRole('button', { name: '采用远端配置', exact: true }).click(); - const confirm = page.getByRole('dialog', { name: '采用远端配置', exact: true }); - await expect(confirm).toContainText('本机独有的修改和资源会被移除'); - await confirm.getByRole('button', { name: '确认同步', exact: true }).click(); - await expect(panel.getByText('已应用远端配置', { exact: true })).toBeVisible(); - expect(lastSync).toMatchObject({ resolution: 'remote', localHash: 'local-revision', remoteHash: 'remote-revision' }); - expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toContain('shared-sync-passphrase'); -}); diff --git a/web/tests/target-auth.spec.ts b/web/tests/target-auth.spec.ts new file mode 100644 index 0000000..c921c5d --- /dev/null +++ b/web/tests/target-auth.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test'; +import { targetDraftPayload } from '../src/domain/targetDraft'; +import { validateTarget, validateGateway } from '../src/domain/formValidation'; +import { changeTargetAuth } from '../src/domain/targetAuth'; + +test('无认证与空密码草稿不混淆保留、清除语义,也不放宽网关校验', () => { + const account: any = { id: 'chosen', name: 'Fixture', username: 'fixture', password: '', authMethod: 'password' }; + const draft: any = { name: 'Fixture', type: 'mysql', targetHost: 'fixture.invalid', targetPort: 3306, accounts: [account] }; + expect(validateTarget(draft)).toHaveLength(0); + expect(targetDraftPayload(draft).accounts[0]).not.toHaveProperty('password'); + account.clearPassword = true; + expect(targetDraftPayload(draft).accounts[0].password).toBe(''); + expect(targetDraftPayload(draft).accounts[0]).not.toHaveProperty('clearPassword'); + account.authMethod = 'none'; + account.password = 'old-secret'; + account.privateKey = 'old-key'; + account.privateKeyAssetId = 'old-asset'; + account.certificateAssetId = 'old-certificate'; + account.passphrase = 'old-phrase'; + const anonymous = targetDraftPayload(draft).accounts[0]; + for (const key of ['username', 'password', 'privateKey', 'privateKeyAssetId', 'certificate', 'certificateAssetId', 'passphrase']) expect(anonymous[key]).toBe(''); + expect(validateTarget(draft).some(issue => issue.path.endsWith('.authMethod'))).toBeTruthy(); + for (const protocol of ['mongodb', 'redis', 'http', 'https']) expect(validateTarget({ ...draft, type: protocol })).toHaveLength(0); + changeTargetAuth(account); + expect(account.password).toBe(''); + expect(account.username).toBe(''); + expect(account.clearPassword).toBe(false); + expect(validateGateway({ name: 'Gateway', type: 'mongodb', listenHost: '127.0.0.1', listenPort: 27018, gatewayAccounts: [{ id: 'client', username: 'client', password: '' }] }).some(issue => issue.path.endsWith('.password'))).toBeTruthy(); +}); + +for (const [protocol, label] of [['mongodb', 'MongoDB'], ['redis', 'Redis'], ['http', 'HTTP'], ['https', 'HTTPS']]) { + test(`${label} 可新建无认证目标,测试与保存一致并回读`, async ({ page }) => { + page.setDefaultTimeout(10000); + await page.goto('/'); + const session = await (await page.request.get('/api/v1/session')).json(); + const headers = { 'X-CSRF-Token': session.csrfToken }; + let targetId = ''; + try { + await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click(); + await page.getByRole('button', { name: '+ 新建目标', exact: true }).click(); + const editor = page.getByRole('dialog', { name: '新建目标服务', exact: true }); + await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: label, exact: true }) }).click(); + await editor.getByPlaceholder('例如:生产数据库 · 只读分析').fill(`${label} 无认证回归`); + await editor.locator('[data-field="targetHost"] input').fill('fixture.invalid'); + if (['mongodb', 'redis'].includes(protocol)) { + await editor.locator('.el-select').filter({ has: page.getByRole('combobox', { name: '部署模式', exact: true }) }).click(); + await page.getByRole('option', { name: '单节点', exact: true }).click(); + } + await editor.getByRole('tab', { name: '账号', exact: true }).click(); + await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: '无认证', exact: true }) }).click(); + await expect(editor.getByRole('textbox', { name: '登录用户名', exact: true })).toHaveCount(0); + await expect(editor.getByLabel('账号密码', { exact: true })).toHaveCount(0); + await page.route('**/api/v1/targets/test', route => route.fulfill({ json: { version: 'anonymous-fixture', tls: false } })); + const testing = page.waitForRequest(request => request.url().endsWith('/api/v1/targets/test')); + await editor.getByRole('button', { name: '测试连接', exact: true }).click(); + const tested = (await testing).postDataJSON(); + expect(tested.config.accounts[0]).toMatchObject({ authMethod: 'none', username: '', password: '' }); + const result = page.getByRole('dialog', { name: '连接测试', exact: true }); + await expect(result).toContainText('anonymous-fixture'); + await result.getByRole('button', { name: '关闭', exact: true }).click(); + const saving = page.waitForResponse(response => response.url().endsWith('/api/v1/targets') && response.request().method() === 'POST'); + await editor.getByRole('button', { name: '保存配置', exact: true }).click(); + const response = await saving; + expect(response.ok(), await response.text()).toBeTruthy(); + const saved = await response.json(); + targetId = saved.config.id; + expect(saved.config.accounts[0]).toMatchObject({ authMethod: 'none', username: '' }); + expect(saved.secretStatus[`account:${saved.config.accounts[0].id}:password`]).toBe(false); + await expect(editor).not.toBeVisible(); + await page.reload(); + await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click(); + await page.locator('.target-card').filter({ hasText: `${label} 无认证回归` }).getByRole('button', { name: '配置目标', exact: true }).click(); + const editing = page.getByRole('dialog', { name: '编辑目标服务', exact: true }); + await editing.getByRole('tab', { name: '账号', exact: true }).click(); + await expect(editing.getByRole('radio', { name: '无认证', exact: true })).toBeChecked(); + if (protocol === 'mongodb') { + await editing.getByRole('tab', { name: '连接信息', exact: true }).click(); + await editing.getByLabel('MongoDB 连接串', { exact: true }).fill('mongodb://fixture:imported-password@fixture.invalid:27017/?directConnection=true'); + await editing.getByRole('button', { name: '解析并填入配置', exact: true }).click(); + await editing.getByRole('tab', { name: '账号', exact: true }).click(); + await expect(editing.getByRole('radio', { name: '密码', exact: true })).toBeChecked(); + await expect(editing.getByLabel('账号密码', { exact: true })).toHaveValue('imported-password'); + } + } finally { + await page.unrouteAll({ behavior: 'wait' }); + if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers }); + } + }); +} + +test('旧 Mongo 账号默认密码模式,切换无认证清除已有凭据且支持小屏', async ({ page }) => { + page.setDefaultTimeout(10000); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + const session = await (await page.request.get('/api/v1/session')).json(); + const headers = { 'X-CSRF-Token': session.csrfToken }; + let targetId = ''; + try { + const created = await page.request.post('/api/v1/targets', { headers, data: { + name: '切换无认证回归', type: 'mongodb', targetHost: 'fixture.invalid', targetPort: 27017, + accounts: [{ id: 'chosen', name: 'Fixture', username: 'fixture', password: 'saved-secret' }], + } }); + expect(created.ok()).toBeTruthy(); + targetId = (await created.json()).config.id; + await page.reload(); + await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click(); + await page.locator('.target-card').filter({ hasText: '切换无认证回归' }).getByRole('button', { name: '配置目标', exact: true }).click(); + const editor = page.getByRole('dialog', { name: '编辑目标服务', exact: true }); + await editor.getByRole('tab', { name: '账号', exact: true }).click(); + await expect(editor.getByRole('radio', { name: '密码', exact: true })).toBeChecked(); + await editor.locator('.el-radio-button').filter({ has: page.getByRole('radio', { name: '无认证', exact: true }) }).click(); + await expect(editor.getByRole('textbox', { name: '登录用户名', exact: true })).toHaveCount(0); + await expect.poll(() => editor.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + await page.screenshot({ path: 'test-results/target-no-auth-mobile.png' }); + const saving = page.waitForResponse(response => response.url().endsWith(`/api/v1/targets/${targetId}`) && response.request().method() === 'PUT'); + await editor.getByRole('button', { name: '保存配置', exact: true }).click(); + expect((await saving).ok()).toBeTruthy(); + const saved = await (await page.request.get(`/api/v1/targets/${targetId}`)).json(); + expect(saved.secretStatus['account:chosen:password']).toBe(false); + expect(saved.config.accounts[0]).toMatchObject({ username: '', authMethod: 'none' }); + } finally { + if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers }); + } +}); + +test('编辑已有账号留空保留密码,勾选空密码才在测试和保存时清除', async ({ page }) => { + page.setDefaultTimeout(10000); + await page.goto('/'); + const session = await (await page.request.get('/api/v1/session')).json(); + const headers = { 'X-CSRF-Token': session.csrfToken }; + let targetId = ''; + try { + const response = await page.request.post('/api/v1/targets', { headers, data: { + name: '空密码回归', type: 'mysql', targetHost: 'fixture.invalid', targetPort: 3306, + accounts: [{ id: 'chosen', name: 'Fixture', username: 'fixture', password: 'saved-secret', authMethod: 'password' }], + } }); + expect(response.ok()).toBeTruthy(); + targetId = (await response.json()).config.id; + await page.reload(); + await page.getByRole('navigation').getByRole('button', { name: /目标服务/ }).click(); + await page.locator('.target-card').filter({ hasText: '空密码回归' }).getByRole('button', { name: '配置目标', exact: true }).click(); + const editor = page.getByRole('dialog', { name: '编辑目标服务', exact: true }); + await editor.getByRole('tab', { name: '账号', exact: true }).click(); + await expect(editor.getByLabel('账号密码', { exact: true })).toHaveAttribute('placeholder', '留空保留已保存密码'); + await page.route('**/api/v1/targets/test', route => route.fulfill({ json: { version: 'empty-password-fixture', tls: false } })); + for (const clear of [false, true]) { + if (clear) await editor.locator('.el-checkbox').filter({ has: page.getByRole('checkbox', { name: '使用空密码(清除已保存密码)', exact: true }) }).click(); + const testing = page.waitForRequest(request => request.url().endsWith('/api/v1/targets/test')); + await editor.getByRole('button', { name: '测试连接', exact: true }).click(); + const account = (await testing).postDataJSON().config.accounts[0]; + if (clear) expect(account.password).toBe(''); + else expect(account).not.toHaveProperty('password'); + await page.getByRole('dialog', { name: '连接测试', exact: true }).getByRole('button', { name: '关闭', exact: true }).click(); + } + const before = await (await page.request.get(`/api/v1/targets/${targetId}`)).json(); + expect(before.secretStatus['account:chosen:password']).toBe(true); + const saving = page.waitForResponse(response => response.url().endsWith(`/api/v1/targets/${targetId}`) && response.request().method() === 'PUT'); + await editor.getByRole('button', { name: '保存配置', exact: true }).click(); + expect((await saving).ok()).toBeTruthy(); + const after = await (await page.request.get(`/api/v1/targets/${targetId}`)).json(); + expect(after.secretStatus['account:chosen:password']).toBe(false); + expect(after.config.accounts[0].username).toBe('fixture'); + } finally { + await page.unrouteAll({ behavior: 'wait' }); + if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers }); + } +}); diff --git a/web/tests/webdav-sync.spec.ts b/web/tests/webdav-sync.spec.ts new file mode 100644 index 0000000..ead8e42 --- /dev/null +++ b/web/tests/webdav-sync.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; +import { createServer } from 'node:http'; + +test('WebDAV 同步备份设置、冲突处理与自适应布局', async ({ page }) => { + const settings = { endpoint: '', prefix: 'serviceport/', username: '', automatic: false, intervalMinutes: 5, hasPassword: false, hasPassphrase: false }; + const state = { deviceId: 'test-device', lastAttempt: '', lastSuccess: '', lastEndpoint: '', lastKey: '', lastError: '', + running: false, conflict: false, localHash: '', remoteHash: '', action: '' }; + let lastSave: any, lastSync: any; + await page.route('**/api/v1/configuration/webdav/settings', async route => { + if (route.request().method() === 'PUT') { + lastSave = route.request().postDataJSON(); + Object.assign(settings, { endpoint: lastSave.endpoint, username: lastSave.username, automatic: lastSave.automatic, + hasPassword: true, hasPassphrase: true }); + } + await route.fulfill({ json: settings }); + }); + await page.route('**/api/v1/configuration/webdav/status', route => route.fulfill({ json: { state, nextRun: '0001-01-01T00:00:00Z' } })); + await page.route('**/api/v1/configuration/webdav/sync', async route => { + lastSync = route.request().postDataJSON(); + if (!lastSync.resolution) { + Object.assign(state, { conflict: true, localHash: 'local-revision', remoteHash: 'remote-revision', action: 'conflict', lastError: '两端配置均有变化' }); + await route.fulfill({ status: 409, json: { error: '两端配置均有变化' } }); + } else { + Object.assign(state, { conflict: false, action: 'pulled', lastSuccess: new Date().toISOString(), lastError: '' }); + await route.fulfill({ json: { action: 'pulled' } }); + } + }); + await page.goto('/'); + await page.getByRole('navigation').getByRole('button', { name: '监控运维', exact: true }).click(); + const panel = page.getByRole('region', { name: 'WebDAV 同步备份', exact: true }); + await expect(panel).toHaveCount(0); + await expect(page.getByRole('heading', { name: '配置备份', exact: true })).toHaveCount(0); + await expect(page.getByRole('region', { name: '配置模板管理', exact: true })).toHaveCount(0); + await page.getByRole('navigation').getByRole('button', { name: '配置管理', exact: true }).click(); + await expect(page.getByRole('heading', { name: '配置备份', exact: true })).toBeVisible(); + await expect(panel).toHaveCount(0); + await page.getByRole('navigation').getByRole('button', { name: '多端同步', exact: true }).click(); + await expect(page.getByRole('heading', { name: '配置备份', exact: true })).toHaveCount(0); + await expect(panel).toBeVisible(); + await expect(panel).toContainText('监听地址、端口、账号映射'); + await panel.getByRole('textbox', { name: 'WebDAV 地址', exact: true }).fill('https://dav.example.com/root/'); + await panel.getByRole('textbox', { name: 'WebDAV 用户名', exact: true }).fill('access-id'); + await panel.getByRole('textbox', { name: 'WebDAV 密码', exact: true }).fill('secret-access-key'); + await panel.getByRole('textbox', { name: 'WebDAV 同步口令', exact: true }).fill('shared-sync-passphrase'); + await panel.locator('.el-switch').click(); + await expect(panel.getByRole('spinbutton', { name: '自动同步间隔' })).toHaveValue('5'); + await panel.getByRole('button', { name: '保存 WebDAV 设置', exact: true }).click(); + await expect(panel.getByRole('textbox', { name: 'WebDAV 同步口令', exact: true })).toHaveValue(''); + expect(lastSave.automatic).toBe(true); + expect(lastSave.passphrase).toBe('shared-sync-passphrase'); + await panel.getByRole('button', { name: '立即同步', exact: true }).click(); + await expect(panel.getByText('同步冲突:本机与远端配置不同', { exact: true })).toBeVisible(); + for (const viewport of [{ width: 320, height: 568 }, { width: 390, height: 844 }, { width: 768, height: 640 }, { width: 1920, height: 1080 }]) { + await page.setViewportSize(viewport); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } + await panel.getByRole('button', { name: '采用远端配置', exact: true }).click(); + const confirm = page.getByRole('dialog', { name: '采用远端配置', exact: true }); + await expect(confirm).toContainText('本机独有的修改和资源会被移除'); + await confirm.getByRole('button', { name: '确认同步', exact: true }).click(); + await expect(panel.getByText('已应用远端配置', { exact: true })).toBeVisible(); + expect(lastSync).toMatchObject({ resolution: 'remote', localHash: 'local-revision', remoteHash: 'remote-revision' }); + expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toContain('shared-sync-passphrase'); +}); + +test('WebDAV 页面通过真实隔离 API 保存凭据、独立备份并同步加密配置', async ({ page }) => { + page.setDefaultTimeout(10000); + const files = new Map(); + const server = createServer((request, response) => { + if (request.headers.authorization !== `Basic ${Buffer.from('fixture:fixture-secret').toString('base64')}`) { response.writeHead(401).end(); return; } + if (request.method === 'MKCOL') { response.writeHead(201).end(); return; } + const key = request.url || ''; + const file = files.get(key); + if (request.method === 'GET') { + if (!file) response.writeHead(404).end(); + else response.writeHead(200, { ETag: file.tag }).end(file.content); + return; + } + if (request.method !== 'PUT') { response.writeHead(405).end(); return; } + if ((file && request.headers['if-match'] !== file.tag) || (!file && request.headers['if-none-match'] !== '*')) { response.writeHead(412).end(); return; } + const chunks: Buffer[] = []; + request.on('data', chunk => chunks.push(chunk)); + request.on('end', () => { + files.set(key, { content: Buffer.concat(chunks), tag: `"${files.size + 1}"` }); + response.writeHead(201).end(); + }); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('fixture failed to listen'); + let targetId = ''; + let headers: Record = {}; + try { + await page.goto('/'); + const session = await (await page.request.get('/api/v1/session')).json(); + headers = { 'X-CSRF-Token': session.csrfToken }; + const created = await page.request.post('/api/v1/targets', { headers, data: { + name: 'WebDAV fixture target', type: 'http', targetHost: 'fixture.invalid', targetPort: 80, + accounts: [{ id: 'anonymous', name: 'Anonymous', authMethod: 'none' }], + } }); + expect(created.ok()).toBeTruthy(); + targetId = (await created.json()).config.id; + await page.getByRole('navigation').getByRole('button', { name: '多端同步', exact: true }).click(); + const panel = page.getByRole('region', { name: 'WebDAV 同步备份', exact: true }); + await panel.getByRole('textbox', { name: 'WebDAV 地址', exact: true }).fill(`http://127.0.0.1:${address.port}/dav/user/`); + await panel.getByRole('textbox', { name: 'WebDAV 用户名', exact: true }).fill('fixture'); + await panel.getByRole('textbox', { name: 'WebDAV 密码', exact: true }).fill('fixture-secret'); + await panel.getByRole('textbox', { name: 'WebDAV 同步口令', exact: true }).fill('shared-fixture-passphrase'); + const saving = page.waitForResponse(response => response.url().endsWith('/configuration/webdav/settings') && response.request().method() === 'PUT'); + await panel.getByRole('button', { name: '保存 WebDAV 设置', exact: true }).click(); + const saved = await saving; + expect(saved.ok()).toBeTruthy(); + expect(await saved.text()).not.toContain('fixture-secret'); + await expect(panel.getByRole('textbox', { name: 'WebDAV 密码', exact: true })).toHaveValue(''); + const backingUp = page.waitForResponse(response => response.url().endsWith('/configuration/webdav/backup')); + await panel.getByRole('button', { name: '立即备份本机', exact: true }).click(); + const backedUp = await backingUp; + expect(backedUp.ok(), await backedUp.text()).toBeTruthy(); + expect((await backedUp.json()).action).toBe('backed_up'); + await expect(panel.getByText('已备份本机配置', { exact: true })).toBeVisible(); + expect([...files.keys()].every(key => key.startsWith('/dav/user/serviceport/backups/'))).toBe(true); + await panel.getByRole('button', { name: '立即同步', exact: true }).click(); + await expect(panel.getByText('已推送本机配置', { exact: true })).toBeVisible(); + expect(files.has('/dav/user/serviceport/sync/configuration.json')).toBe(true); + for (const file of files.values()) { + const raw = file.content.toString(); + expect(JSON.parse(raw)).toHaveProperty('ciphertext'); + expect(raw).not.toContain('WebDAV fixture target'); + expect(raw).not.toContain('fixture-secret'); + } + const settings = await (await page.request.get('/api/v1/configuration/webdav/settings')).json(); + expect(settings).toMatchObject({ username: 'fixture', hasPassword: true, hasPassphrase: true, automatic: false }); + expect((await page.request.get('/api/v1/configuration/s3/settings')).status()).toBe(404); + expect(await page.evaluate(() => JSON.stringify(localStorage))).not.toContain('shared-fixture-passphrase'); + } finally { + if (targetId) await page.request.delete(`/api/v1/targets/${targetId}`, { headers }); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + } +});