mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ae6247f9 | |||
| 1088086399 | |||
| 2c74d042ed |
@@ -98,10 +98,11 @@ Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置
|
|||||||
* `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。
|
* `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。
|
||||||
* `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
|
* `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
|
||||||
* `waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
|
* `waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
|
||||||
|
* `pages_dir`:Pages 静态站点部署目录,默认位于 `data_dir/var/lib/openflare/pages`。当激活配置引用 Pages 部署时,Agent 会下载部署 zip、校验 checksum、解压到部署 release 目录,并切换 `deployments/{deployment_id}/current` 供 OpenResty `root`/`try_files` 读取。
|
||||||
|
|
||||||
### 2. 精细化的重载动作
|
### 2. 精细化的重载动作
|
||||||
1. **备份当前配置**:在写入新文件之前,Agent 会将现有的配置文件复制到 `.backup` 临时目录下,保留完整的现场快照。
|
1. **备份当前配置**:在写入新文件之前,Agent 会将现有的配置文件复制到 `.backup` 临时目录下,保留完整的现场快照。
|
||||||
2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`)替换为本地实际运行路径。
|
2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`、`__OPENFLARE_PAGES_DIR__`)替换为本地实际运行路径。
|
||||||
3. **语法校验**:调用 `openresty -t -c <temp_nginx.conf>` 进行严格的语法测试。
|
3. **语法校验**:调用 `openresty -t -c <temp_nginx.conf>` 进行严格的语法测试。
|
||||||
4. **平滑重载**:若校验通过,将新配置移至正式路径,执行 `openresty -s reload`。若 OpenResty 处于未启动状态,则使用当前配置拉起进程。
|
4. **平滑重载**:若校验通过,将新配置移至正式路径,执行 `openresty -s reload`。若 OpenResty 处于未启动状态,则使用当前配置拉起进程。
|
||||||
5. **捕获异常**:校验或重载失败时,Agent 会截获标准错误输出(stderr),提取前 2000 个字符的详细报错信息。
|
5. **捕获异常**:校验或重载失败时,Agent 会截获标准错误输出(stderr),提取前 2000 个字符的详细报错信息。
|
||||||
@@ -117,7 +118,7 @@ OpenFlare 摒弃了动态 Patch 节点配置的落后方式,采用 **不可变
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 1. 核心设计原则
|
### 1. 核心设计原则
|
||||||
* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。
|
* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、Pages 部署引用、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。
|
||||||
* **版本格式**:采用 `YYYYMMDD-NNN` 递增格式,确保版本历史直观、具备单调递增性。
|
* **版本格式**:采用 `YYYYMMDD-NNN` 递增格式,确保版本历史直观、具备单调递增性。
|
||||||
* **全局单激活版本**:系统同时只有一个处于 `active` 状态的全局配置版本。回滚时无需逆向打补丁,只需将历史某个健康版本的状态改为 `active`,Agent 重新拉取应用即可。
|
* **全局单激活版本**:系统同时只有一个处于 `active` 状态的全局配置版本。回滚时无需逆向打补丁,只需将历史某个健康版本的状态改为 `active`,Agent 重新拉取应用即可。
|
||||||
|
|
||||||
@@ -171,4 +172,4 @@ graph TD
|
|||||||
|
|
||||||
1. **零特权指令通道**:Server 绝对禁止向 Agent 传递任何任意 shell 命令或远程执行脚本(如 exec/eval 等)。所有系统控制原语(如启动、停止、重载、更新)必须硬编码在 Agent 二进制内部。
|
1. **零特权指令通道**:Server 绝对禁止向 Agent 传递任何任意 shell 命令或远程执行脚本(如 exec/eval 等)。所有系统控制原语(如启动、停止、重载、更新)必须硬编码在 Agent 二进制内部。
|
||||||
2. **严格的 Token 过滤与前缀验证**:Agent 侧向 Server 请求资源时,接口端点固定以 `/api/agent/` 为前缀,并强制携带 `X-Agent-Token` 进行签名或令牌核验。
|
2. **严格的 Token 过滤与前缀验证**:Agent 侧向 Server 请求资源时,接口端点固定以 `/api/agent/` 为前缀,并强制携带 `X-Agent-Token` 进行签名或令牌核验。
|
||||||
3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。
|
3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。
|
||||||
|
|||||||
@@ -48,13 +48,27 @@ OpenFlared (frpc) <-- 内网服务器
|
|||||||
Internal Service (192.168.x.x)
|
Internal Service (192.168.x.x)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Pages 静态托管流量路径
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser
|
||||||
|
|
|
||||||
|
| HTTPS request
|
||||||
|
v
|
||||||
|
OpenResty (Agent, TLS/WAF)
|
||||||
|
|
|
||||||
|
| root/try_files
|
||||||
|
v
|
||||||
|
Agent 本地 Pages 部署目录
|
||||||
|
```
|
||||||
|
|
||||||
## 组件职责
|
## 组件职责
|
||||||
|
|
||||||
| 组件 | 职责 |
|
| 组件 | 职责 |
|
||||||
| --------------- | ---------------------------------------------------------------------- |
|
| --------------- | ---------------------------------------------------------------------- |
|
||||||
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、数据存储与聚合查询 |
|
| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、Pages 部署包存储、数据存储与聚合查询 |
|
||||||
| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 |
|
| Agent | 注册、心跳、同步、写入文件、Pages 部署包拉取与解压、校验、reload、失败回滚、自更新与轻量采集 |
|
||||||
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 |
|
| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证、反向代理与 Pages 静态文件服务 |
|
||||||
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
|
| OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 |
|
||||||
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
|
| OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 |
|
||||||
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
|
| Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 |
|
||||||
@@ -71,6 +85,8 @@ Internal Service (192.168.x.x)
|
|||||||
|
|
||||||
Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。
|
Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。
|
||||||
|
|
||||||
|
Pages 静态托管场景中,Server 保存 Pages 项目、SPA fallback 回退路径、不可变部署元数据、文件清单和 zip 部署包;发布版本只记录部署引用、checksum 与静态渲染策略,不把大体积静态资源写入 `config_versions`。
|
||||||
|
|
||||||
## Agent
|
## Agent
|
||||||
|
|
||||||
`openflare_agent` 是 Go 单体程序:
|
`openflare_agent` 是 Go 单体程序:
|
||||||
@@ -79,6 +95,7 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存
|
|||||||
* 启动后读取或生成本地节点信息。
|
* 启动后读取或生成本地节点信息。
|
||||||
* 周期性 heartbeat,上报状态并获取激活版本摘要。
|
* 周期性 heartbeat,上报状态并获取激活版本摘要。
|
||||||
* 发现新版本后拉取配置、备份旧文件、写入新文件、校验并 reload。
|
* 发现新版本后拉取配置、备份旧文件、写入新文件、校验并 reload。
|
||||||
|
* 当激活配置引用 Pages 部署时,先按部署 ID 下载 zip 包,校验 checksum,解压到本地 `pages_dir` 并切换当前部署目录。
|
||||||
* 应用失败时尝试恢复运行并回滚。
|
* 应用失败时尝试恢复运行并回滚。
|
||||||
* 维护 WAF GeoIP mmdb,启动时写入内置初始库,并按配置定期更新。
|
* 维护 WAF GeoIP mmdb,启动时写入内置初始库,并按配置定期更新。
|
||||||
|
|
||||||
@@ -118,6 +135,7 @@ Browser -> Frontend -> /api/* -> controller -> service -> model -> database
|
|||||||
```text
|
```text
|
||||||
Agent HTTP heartbeat -> Server 返回激活版本摘要
|
Agent HTTP heartbeat -> Server 返回激活版本摘要
|
||||||
Agent 发现新版本 -> 拉取配置详情
|
Agent 发现新版本 -> 拉取配置详情
|
||||||
|
Agent 确保 Pages 部署包已下载、校验并解压 (如配置引用 Pages)
|
||||||
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
|
Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置
|
||||||
Agent 执行 OpenResty 校验与 reload
|
Agent 执行 OpenResty 校验与 reload
|
||||||
Agent 上报应用结果
|
Agent 上报应用结果
|
||||||
@@ -180,6 +198,9 @@ WAF IP 组由 Server 管理。手动 IP 组直接保存 IP/IP 段列表;自动
|
|||||||
* `proxy_routes`
|
* `proxy_routes`
|
||||||
* `origins`
|
* `origins`
|
||||||
* `config_versions`
|
* `config_versions`
|
||||||
|
* `pages_projects`
|
||||||
|
* `pages_deployments`
|
||||||
|
* `pages_deployment_files`
|
||||||
* `nodes`
|
* `nodes`
|
||||||
* `tunnels`
|
* `tunnels`
|
||||||
* `auth_sources`
|
* `auth_sources`
|
||||||
|
|||||||
+17
-1
@@ -32,6 +32,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
|||||||
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
||||||
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
|
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
|
||||||
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
|
| 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 |
|
||||||
|
| Pages 静态托管 | 以 Pages 项目管理静态站点部署包,发布后由边缘 Agent 拉取并在本地 OpenResty 静态服务 |
|
||||||
|
|
||||||
默认工作方式:
|
默认工作方式:
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
|||||||
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
| 证书托管 | 为不同域名绑定 TLS 证书 |
|
||||||
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
|
| 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 |
|
||||||
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
|
| 内网穿透 | 通过 Tunnel 将无法直接公网访问的内网 HTTP 服务暴露到互联网,享有 HTTPS、WAF 等全部防护能力 |
|
||||||
|
| 静态站点托管 | 上传已构建的静态资源包,将网站规则上游绑定到 Pages 项目,在边缘节点本地服务静态文件 |
|
||||||
|
|
||||||
|
|
||||||
## 网站配置约束
|
## 网站配置约束
|
||||||
@@ -72,12 +74,26 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre
|
|||||||
|
|
||||||
上游约束:
|
上游约束:
|
||||||
|
|
||||||
* `proxy_routes` 至少包含一个上游地址(直连类型 `direct`),或关联一个 Tunnel(内网穿透类型 `tunnel`)。
|
* `proxy_routes` 至少包含一个上游地址(直连类型 `direct`),或关联一个 Tunnel(内网穿透类型 `tunnel`),或关联一个 Pages 项目(静态托管类型 `pages`)。
|
||||||
* 多上游负载均衡统一渲染为带 keepalive 的 named `upstream`。
|
* 多上游负载均衡统一渲染为带 keepalive 的 named `upstream`。
|
||||||
* 单上游允许附带 base path 或 query,并在 `proxy_pass` 中追加。多上游限定为纯 `scheme://host[:port]` 结构,且同一规则内的协议必须一致。
|
* 单上游允许附带 base path 或 query,并在 `proxy_pass` 中追加。多上游限定为纯 `scheme://host[:port]` 结构,且同一规则内的协议必须一致。
|
||||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
|
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。
|
||||||
* 所有直连类型上游地址都必须为合法的 `http://` 或 `https://`。
|
* 所有直连类型上游地址都必须为合法的 `http://` 或 `https://`。
|
||||||
* 内网穿透类型上游必须关联有效 `tunnel_id`,并指定内网目标地址与协议。
|
* 内网穿透类型上游必须关联有效 `tunnel_id`,并指定内网目标地址与协议。
|
||||||
|
* Pages 类型上游必须关联有效 Pages 项目,且项目必须存在已激活部署。Pages 站点不执行服务端构建、边缘函数或动态运行时代码,仅托管预构建静态资源。
|
||||||
|
|
||||||
|
## Pages 静态托管约束
|
||||||
|
|
||||||
|
OpenFlare Pages 面向边缘节点静态站点托管,采用“项目 + 不可变部署 + 网站规则绑定”的模型。
|
||||||
|
|
||||||
|
约束:
|
||||||
|
|
||||||
|
* Pages 项目保存名称、标识、启用状态、SPA fallback 启用状态、自定义回退路径和当前激活部署。
|
||||||
|
* Pages 部署由管理端上传预构建 zip 包生成;部署包保存在 Server 本地 Pages 存储目录,数据库只保存部署元数据和文件清单,不保存大体积文件内容。
|
||||||
|
* 只有项目存在激活部署后,`proxy_routes.upstream_type = 'pages'` 的网站规则才能绑定该项目。
|
||||||
|
* Pages 网站继续复用网站规则的域名、HTTPS、WAF、PoW、Basic Auth、限流、缓存配置和配置版本发布机制。
|
||||||
|
* 发布快照保存 Pages 项目、部署 ID、部署 checksum、入口文件、SPA fallback 启用状态和回退路径。Agent 拉取激活配置时按部署 checksum 下载并校验部署包,解压到本地 `pages_dir` 后再应用 OpenResty 配置。
|
||||||
|
* V1 不支持 Git 自动构建、预览域名、边缘函数、动态 SSR、外部对象存储或多租户隔离。
|
||||||
|
|
||||||
## 内网穿透约束
|
## 内网穿透约束
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ OpenFlare 不直接在线修改节点上的 Nginx/OpenResty 配置。你在管
|
|||||||
* 修改源站目录后,检查已发布的网站配置是否需要同步更新源站快照。
|
* 修改源站目录后,检查已发布的网站配置是否需要同步更新源站快照。
|
||||||
* 发布前使用预览或 diff 确认渲染结果。
|
* 发布前使用预览或 diff 确认渲染结果。
|
||||||
|
|
||||||
|
## 托管 Pages 静态站点
|
||||||
|
|
||||||
|
Pages 用于托管已经构建完成的静态资源包。当前阶段只支持 Direct Upload,不执行 Git 构建、边缘函数或 SSR。
|
||||||
|
|
||||||
|
操作顺序:
|
||||||
|
|
||||||
|
1. 进入 **Pages** 页面,点击 **新建 Pages 项目**。
|
||||||
|
2. 填写项目名称、标识、描述;如为前端 history 路由应用,启用 **SPA fallback** 并填写回退路径,默认是 `/index.html`,也可以设置为 `/app.html` 等站点内绝对路径。
|
||||||
|
3. 创建后回到 Pages 项目列表,点击项目进入详情。
|
||||||
|
4. 在项目详情中上传 zip 静态资源包,并激活某个部署。
|
||||||
|
5. 新建或编辑网站规则,将回源方式切换为 **Pages 静态站点**,选择该 Pages 项目。
|
||||||
|
6. 发布并激活配置版本,Agent 会下载部署包、校验 checksum、解压到本地 Pages 目录,再由 OpenResty 本地服务静态文件。
|
||||||
|
|
||||||
|
Pages 项目只有在启用且存在激活部署后,才会出现在网站规则的 Pages 项目选择列表中。
|
||||||
|
|
||||||
## 启用 HTTPS
|
## 启用 HTTPS
|
||||||
|
|
||||||
HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
HTTPS 按域名绑定证书,而不是按整个网站统一强制启用。
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ Frontend:
|
|||||||
|
|
||||||
### 1. 当前有效实体
|
### 1. 当前有效实体
|
||||||
* **核心配置与反代**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
|
* **核心配置与反代**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名).
|
||||||
|
* **Pages 静态托管**:`pages_projects` (Pages 项目), `pages_deployments` (不可变部署), `pages_deployment_files` (部署文件清单).
|
||||||
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
* **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志).
|
||||||
* **内网穿透**:`tunnels` (隧道客户端), `tunnel_tokens` (隧道认证令牌,可选持久化).
|
* **内网穿透**:`tunnels` (隧道客户端), `tunnel_tokens` (隧道认证令牌,可选持久化).
|
||||||
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
|
* **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件).
|
||||||
@@ -94,6 +95,13 @@ Frontend:
|
|||||||
* 必须指定 `tunnel_target_addr`(内网目标地址,如 `192.168.1.100:8080`)和 `tunnel_target_protocol`(`http` 或 `https`)。
|
* 必须指定 `tunnel_target_addr`(内网目标地址,如 `192.168.1.100:8080`)和 `tunnel_target_protocol`(`http` 或 `https`)。
|
||||||
* 发布配置时,Server 自动将此上游渲染为 `http://127.0.0.1:{relay_vhost_port}`,Agent 依据 Host 头由 frps 路由。
|
* 发布配置时,Server 自动将此上游渲染为 `http://127.0.0.1:{relay_vhost_port}`,Agent 依据 Host 头由 frps 路由。
|
||||||
|
|
||||||
|
* **Pages 与上游关联**:
|
||||||
|
* `proxy_routes.upstream_type = 'pages'` 时,必须指定 `pages_project_id`。
|
||||||
|
* 被引用的 Pages 项目必须启用,且必须存在当前激活部署。
|
||||||
|
* Pages 项目可启用 SPA fallback 并配置站点内绝对回退路径(默认 `/index.html`);回退路径必须经 Server 校验后进入发布快照,不得直接拼接未校验输入到 OpenResty 配置。
|
||||||
|
* Pages 部署包必须作为 Server 本地文件保存,数据库只保存部署元数据与文件清单;禁止把静态资源内容写入 `config_versions.support_files_json`。
|
||||||
|
* 发布配置时,Server 将 Pages 上游渲染为 OpenResty `root` + `try_files` 静态服务,并保留网站规则已有的 HTTPS、WAF、PoW、Basic Auth、限流与缓存配置。
|
||||||
|
|
||||||
* **TunnelRelay 节点配置**:
|
* **TunnelRelay 节点配置**:
|
||||||
* `nodes.node_type = 'tunnel_relay'` 时,新增字段 `relay_bind_port`、`relay_vhost_http_port`、`relay_auth_token` 必须有合理默认值。
|
* `nodes.node_type = 'tunnel_relay'` 时,新增字段 `relay_bind_port`、`relay_vhost_http_port`、`relay_auth_token` 必须有合理默认值。
|
||||||
* `relay_bind_port` 默认 7000,`relay_vhost_http_port` 默认 8080。
|
* `relay_bind_port` 默认 7000,`relay_vhost_http_port` 默认 8080。
|
||||||
@@ -163,6 +171,11 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是
|
|||||||
- CRUD tunnel 实体(创建、查询、更新、删除)。
|
- CRUD tunnel 实体(创建、查询、更新、删除)。
|
||||||
- Token 管理(生成、轮换)。
|
- Token 管理(生成、轮换)。
|
||||||
- 强制同步(触发 Client 立即拉取新配置)。
|
- 强制同步(触发 Client 立即拉取新配置)。
|
||||||
|
* **Admin Pages 管理 API** - `/api/pages/*`,要求 Admin Session。
|
||||||
|
- CRUD Pages 项目,包括 SPA fallback 启用状态与回退路径。
|
||||||
|
- 上传 zip 部署包、查看部署历史、激活部署、删除非激活部署。
|
||||||
|
* **Agent Pages 下载 API** - `/api/agent/pages/*`,使用 `X-Agent-Token` 认证。
|
||||||
|
- Agent 仅能按激活配置引用的部署 ID 拉取静态部署包,不提供任意文件读取或远程命令入口。
|
||||||
* 总览与节点详情优先使用专用聚合接口。
|
* 总览与节点详情优先使用专用聚合接口。
|
||||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
||||||
* 管理端继续复用现有登录、角色与 Session。
|
* 管理端继续复用现有登录、角色与 Session。
|
||||||
@@ -184,6 +197,9 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是
|
|||||||
* 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。
|
* 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。
|
||||||
* 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。
|
* 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。
|
||||||
* 发布版本不得展开 WAF IP 组成员;Agent 必须通过独立的 IP 组 checksum 差异同步和 WebSocket 增量广播维护本地 `waf_ip_groups.json`。
|
* 发布版本不得展开 WAF IP 组成员;Agent 必须通过独立的 IP 组 checksum 差异同步和 WebSocket 增量广播维护本地 `waf_ip_groups.json`。
|
||||||
|
* **Pages 配置扩展**:区分上游类型,为 `upstream_type = 'pages'` 的代理规则生成 Pages 部署快照。
|
||||||
|
* OpenResty 侧:将 Pages 上游渲染为本地静态目录 `root` 与 `try_files`,启用 SPA fallback 时使用项目配置的回退路径,不得渲染 `proxy_pass`。
|
||||||
|
* Agent 侧:在应用 OpenResty 配置前,必须确保引用的 Pages 部署包已下载、checksum 校验通过并解压到 `pages_dir`。
|
||||||
* **内网穿透配置扩展**:区分上游类型,为 `upstream_type = 'tunnel'` 的代理规则生成独立的 tunnel 配置数据。
|
* **内网穿透配置扩展**:区分上游类型,为 `upstream_type = 'tunnel'` 的代理规则生成独立的 tunnel 配置数据。
|
||||||
* OpenResty 侧:将 tunnel 上游自动渲染为 `http://127.0.0.1:{relay_vhost_port}`,必须保留原始 `Host` 请求头。
|
* OpenResty 侧:将 tunnel 上游自动渲染为 `http://127.0.0.1:{relay_vhost_port}`,必须保留原始 `Host` 请求头。
|
||||||
* Tunnel 侧:为每个 Client 生成完整的 relay 列表与 frpc 代理定义(frpc proxy 配置)。
|
* Tunnel 侧:为每个 Client 生成完整的 relay 列表与 frpc 代理定义(frpc proxy 配置)。
|
||||||
@@ -209,6 +225,7 @@ Agent 必须满足:
|
|||||||
* 发现新版本时先备份旧文件。
|
* 发现新版本时先备份旧文件。
|
||||||
* 写入主配置、路由配置与必要证书文件。
|
* 写入主配置、路由配置与必要证书文件。
|
||||||
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
|
* 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。
|
||||||
|
* 如果激活配置引用 Pages 部署,必须通过 Agent API 拉取部署包,校验配置快照中的 SHA-256 checksum,安全解压并原子切换本地当前部署目录;zip 路径不得逃逸 `pages_dir`,不得接受符号链接。
|
||||||
* WAF IP 组同步必须按组增量更新,不得在每次心跳或每次同步中传输全部 IP 组。
|
* WAF IP 组同步必须按组增量更新,不得在每次心跳或每次同步中传输全部 IP 组。
|
||||||
* 写入新配置后执行 `openresty -t -c <main_config_path>`,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。
|
* 写入新配置后执行 `openresty -t -c <main_config_path>`,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。
|
||||||
* 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。
|
* 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
|||||||
| `OPENFLARE_NODE_IP` | 节点 IP,可覆盖 `agent.json` | 空 |
|
| `OPENFLARE_NODE_IP` | 节点 IP,可覆盖 `agent.json` | 空 |
|
||||||
| `OPENFLARE_DATA_DIR` | Agent 数据目录,可覆盖 `agent.json` | 空 |
|
| `OPENFLARE_DATA_DIR` | Agent 数据目录,可覆盖 `agent.json` | 空 |
|
||||||
| `OPENFLARE_OPENRESTY_PATH` | OpenResty 二进制路径,可覆盖 `agent.json` | 空 |
|
| `OPENFLARE_OPENRESTY_PATH` | OpenResty 二进制路径,可覆盖 `agent.json` | 空 |
|
||||||
|
| `OPENFLARE_PAGES_DIR` | Pages 静态部署目录,可覆盖 `agent.json` | 空 |
|
||||||
| `OPENFLARE_HEARTBEAT_INTERVAL` | 心跳间隔,可覆盖 `agent.json` | 空 |
|
| `OPENFLARE_HEARTBEAT_INTERVAL` | 心跳间隔,可覆盖 `agent.json` | 空 |
|
||||||
| `OPENFLARE_REQUEST_TIMEOUT` | 请求超时,可覆盖 `agent.json` | 空 |
|
| `OPENFLARE_REQUEST_TIMEOUT` | 请求超时,可覆盖 `agent.json` | 空 |
|
||||||
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | 本地观测端口,可覆盖 `agent.json` | 空 |
|
| `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | 本地观测端口,可覆盖 `agent.json` | 空 |
|
||||||
@@ -201,6 +202,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
|||||||
| `lua_dir` | Lua 脚本与静态资源写入目录 | 否 | `data_dir/etc/nginx/lua` |
|
| `lua_dir` | Lua 脚本与静态资源写入目录 | 否 | `data_dir/etc/nginx/lua` |
|
||||||
| `openresty_lua_dir` | OpenResty 配置中读取 Lua 的目录 | 否 | 同 `lua_dir` |
|
| `openresty_lua_dir` | OpenResty 配置中读取 Lua 的目录 | 否 | 同 `lua_dir` |
|
||||||
| `runtime_config_dir` | Agent 运行时配置写入目录,如 `pow_config.json` | 否 | `data_dir/etc/openflare` |
|
| `runtime_config_dir` | Agent 运行时配置写入目录,如 `pow_config.json` | 否 | `data_dir/etc/openflare` |
|
||||||
|
| `pages_dir` | Pages 静态部署包解压与当前部署目录 | 否 | `data_dir/var/lib/openflare/pages` |
|
||||||
| `mmdb_path` | WAF GeoIP mmdb 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
|
| `mmdb_path` | WAF GeoIP mmdb 文件路径 | 否 | `data_dir/etc/openflare/GeoLite2-Country.mmdb` |
|
||||||
| `mmdb_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 |
|
| `mmdb_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 |
|
||||||
| `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 |
|
| `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 |
|
||||||
@@ -218,6 +220,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前
|
|||||||
* 未配置 `openresty_path` 时默认调用 `openresty`。
|
* 未配置 `openresty_path` 时默认调用 `openresty`。
|
||||||
* Agent 周期性健康检查会请求 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c <main_config_path>`。
|
* Agent 周期性健康检查会请求 `http://127.0.0.1:<openresty_observability_port>/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c <main_config_path>`。
|
||||||
* Agent 会初始化并定期更新 `mmdb_path`,供 OpenResty WAF Lua 执行国家级地域规则;更新失败只记录警告,不阻断同步或 reload。
|
* Agent 会初始化并定期更新 `mmdb_path`,供 OpenResty WAF Lua 执行国家级地域规则;更新失败只记录警告,不阻断同步或 reload。
|
||||||
|
* 当激活配置引用 Pages 部署时,Agent 会在应用 OpenResty 配置前,将部署包下载、校验并解压到 `pages_dir`,OpenResty 通过该目录服务静态文件。
|
||||||
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
|
* 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。
|
||||||
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
|
* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。
|
||||||
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
|
* Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。
|
||||||
@@ -334,6 +337,7 @@ go run .
|
|||||||
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
|
"cert_dir": "/var/lib/openflare-agent/etc/nginx/certs",
|
||||||
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
|
"lua_dir": "/var/lib/openflare-agent/etc/nginx/lua",
|
||||||
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
|
"runtime_config_dir": "/var/lib/openflare-agent/etc/openflare",
|
||||||
|
"pages_dir": "/var/lib/openflare-agent/var/lib/openflare/pages",
|
||||||
"heartbeat_interval": 10000,
|
"heartbeat_interval": 10000,
|
||||||
"request_timeout": 10000
|
"request_timeout": 10000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func main() {
|
|||||||
LuaDir: cfg.LuaDir,
|
LuaDir: cfg.LuaDir,
|
||||||
NginxLuaDir: cfg.OpenrestyLuaDir,
|
NginxLuaDir: cfg.OpenrestyLuaDir,
|
||||||
RuntimeConfigDir: cfg.RuntimeConfigDir,
|
RuntimeConfigDir: cfg.RuntimeConfigDir,
|
||||||
|
PagesDir: cfg.PagesDir,
|
||||||
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
|
OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort),
|
||||||
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
|
OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort,
|
||||||
OpenrestyResolverDirective: "",
|
OpenrestyResolverDirective: "",
|
||||||
@@ -89,12 +90,14 @@ func main() {
|
|||||||
slog.Error("ensure managed lua assets failed", "error", err)
|
slog.Error("ensure managed lua assets failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
syncService := syncservice.New(client, runtimeManager, stateStore)
|
||||||
|
syncService.SetPagesDir(cfg.PagesDir)
|
||||||
runner := &agent.Runner{
|
runner := &agent.Runner{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
StateStore: stateStore,
|
StateStore: stateStore,
|
||||||
ObservabilityBuffer: observabilityBuffer,
|
ObservabilityBuffer: observabilityBuffer,
|
||||||
HeartbeatService: heartbeat.New(client),
|
HeartbeatService: heartbeat.New(client),
|
||||||
SyncService: syncservice.New(client, runtimeManager, stateStore),
|
SyncService: syncService,
|
||||||
Updater: updater.New(),
|
Updater: updater.New(),
|
||||||
RuntimeManager: runtimeManager,
|
RuntimeManager: runtimeManager,
|
||||||
WebSocketService: wsClient,
|
WebSocketService: wsClient,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
defaultCertDirRelativePath = "etc/nginx/certs"
|
defaultCertDirRelativePath = "etc/nginx/certs"
|
||||||
defaultLuaDirRelativePath = "etc/nginx/lua"
|
defaultLuaDirRelativePath = "etc/nginx/lua"
|
||||||
defaultRuntimeConfigDirRelativePath = "etc/openflare"
|
defaultRuntimeConfigDirRelativePath = "etc/openflare"
|
||||||
|
defaultPagesDirRelativePath = "var/lib/openflare/pages"
|
||||||
defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb"
|
defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb"
|
||||||
defaultAccessLogRelativePath = "var/log/openflare/access.log"
|
defaultAccessLogRelativePath = "var/log/openflare/access.log"
|
||||||
defaultStateRelativePath = "var/lib/openflare/agent-state.json"
|
defaultStateRelativePath = "var/lib/openflare/agent-state.json"
|
||||||
@@ -57,6 +58,7 @@ type Config struct {
|
|||||||
LuaDir string `json:"lua_dir"`
|
LuaDir string `json:"lua_dir"`
|
||||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||||
|
PagesDir string `json:"pages_dir"`
|
||||||
MMDBPath string `json:"mmdb_path"`
|
MMDBPath string `json:"mmdb_path"`
|
||||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||||
@@ -86,6 +88,7 @@ type configFile struct {
|
|||||||
LuaDir string `json:"lua_dir"`
|
LuaDir string `json:"lua_dir"`
|
||||||
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
OpenrestyLuaDir string `json:"openresty_lua_dir"`
|
||||||
RuntimeConfigDir string `json:"runtime_config_dir"`
|
RuntimeConfigDir string `json:"runtime_config_dir"`
|
||||||
|
PagesDir string `json:"pages_dir"`
|
||||||
MMDBPath string `json:"mmdb_path"`
|
MMDBPath string `json:"mmdb_path"`
|
||||||
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"`
|
||||||
MMDBDownloadURL string `json:"mmdb_download_url"`
|
MMDBDownloadURL string `json:"mmdb_download_url"`
|
||||||
@@ -128,6 +131,7 @@ func Load(path string) (*Config, error) {
|
|||||||
LuaDir: file.LuaDir,
|
LuaDir: file.LuaDir,
|
||||||
OpenrestyLuaDir: file.OpenrestyLuaDir,
|
OpenrestyLuaDir: file.OpenrestyLuaDir,
|
||||||
RuntimeConfigDir: file.RuntimeConfigDir,
|
RuntimeConfigDir: file.RuntimeConfigDir,
|
||||||
|
PagesDir: file.PagesDir,
|
||||||
MMDBPath: file.MMDBPath,
|
MMDBPath: file.MMDBPath,
|
||||||
MMDBUpdateInterval: file.MMDBUpdateInterval,
|
MMDBUpdateInterval: file.MMDBUpdateInterval,
|
||||||
MMDBDownloadURL: file.MMDBDownloadURL,
|
MMDBDownloadURL: file.MMDBDownloadURL,
|
||||||
@@ -190,6 +194,9 @@ func applyDefaults(cfg *Config, baseDir string) {
|
|||||||
if cfg.RuntimeConfigDir == "" {
|
if cfg.RuntimeConfigDir == "" {
|
||||||
cfg.RuntimeConfigDir = joinManagedPath(cfg.DataDir, defaultRuntimeConfigDirRelativePath)
|
cfg.RuntimeConfigDir = joinManagedPath(cfg.DataDir, defaultRuntimeConfigDirRelativePath)
|
||||||
}
|
}
|
||||||
|
if cfg.PagesDir == "" {
|
||||||
|
cfg.PagesDir = joinManagedPath(cfg.DataDir, defaultPagesDirRelativePath)
|
||||||
|
}
|
||||||
if cfg.MMDBPath == "" {
|
if cfg.MMDBPath == "" {
|
||||||
cfg.MMDBPath = joinManagedPath(cfg.DataDir, defaultMMDBRelativePath)
|
cfg.MMDBPath = joinManagedPath(cfg.DataDir, defaultMMDBRelativePath)
|
||||||
}
|
}
|
||||||
@@ -231,6 +238,7 @@ func normalizeManagedPaths(cfg *Config) {
|
|||||||
&cfg.LuaDir,
|
&cfg.LuaDir,
|
||||||
&cfg.OpenrestyLuaDir,
|
&cfg.OpenrestyLuaDir,
|
||||||
&cfg.RuntimeConfigDir,
|
&cfg.RuntimeConfigDir,
|
||||||
|
&cfg.PagesDir,
|
||||||
&cfg.StatePath,
|
&cfg.StatePath,
|
||||||
&cfg.ObservabilityBufferPath,
|
&cfg.ObservabilityBufferPath,
|
||||||
&cfg.MMDBPath,
|
&cfg.MMDBPath,
|
||||||
@@ -251,6 +259,7 @@ func hasEnvConfig() bool {
|
|||||||
"OPENFLARE_NODE_IP",
|
"OPENFLARE_NODE_IP",
|
||||||
"OPENFLARE_DATA_DIR",
|
"OPENFLARE_DATA_DIR",
|
||||||
"OPENFLARE_OPENRESTY_PATH",
|
"OPENFLARE_OPENRESTY_PATH",
|
||||||
|
"OPENFLARE_PAGES_DIR",
|
||||||
"OPENFLARE_HEARTBEAT_INTERVAL",
|
"OPENFLARE_HEARTBEAT_INTERVAL",
|
||||||
"OPENFLARE_REQUEST_TIMEOUT",
|
"OPENFLARE_REQUEST_TIMEOUT",
|
||||||
"OPENFLARE_OPENRESTY_OBSERVABILITY_PORT",
|
"OPENFLARE_OPENRESTY_OBSERVABILITY_PORT",
|
||||||
@@ -281,6 +290,7 @@ func applyEnvOverrides(cfg *Config) {
|
|||||||
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP)
|
||||||
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir)
|
||||||
overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath)
|
overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath)
|
||||||
|
overrideString("OPENFLARE_PAGES_DIR", &cfg.PagesDir)
|
||||||
overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath)
|
overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath)
|
||||||
overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL)
|
overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL)
|
||||||
if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" {
|
if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -86,6 +87,23 @@ func (c *Client) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGrou
|
|||||||
return &resp.Data, nil
|
return &resp.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+fmt.Sprintf("/api/agent/pages/deployments/%d/package", deploymentID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Agent-Token", c.token)
|
||||||
|
res, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New(res.Status)
|
||||||
|
}
|
||||||
|
return io.ReadAll(res.Body)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) SetToken(token string) {
|
func (c *Client) SetToken(token string) {
|
||||||
c.token = strings.TrimSpace(token)
|
c.token = strings.TrimSpace(token)
|
||||||
slog.Debug("http client token updated")
|
slog.Debug("http client token updated")
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ type Manager struct {
|
|||||||
LuaDir string
|
LuaDir string
|
||||||
NginxLuaDir string
|
NginxLuaDir string
|
||||||
RuntimeConfigDir string
|
RuntimeConfigDir string
|
||||||
|
PagesDir string
|
||||||
OpenrestyObservabilityListen string
|
OpenrestyObservabilityListen string
|
||||||
OpenrestyObservabilityPort int
|
OpenrestyObservabilityPort int
|
||||||
OpenrestyResolverDirective string
|
OpenrestyResolverDirective string
|
||||||
@@ -422,6 +423,9 @@ func (m *Manager) CurrentChecksum() (string, error) {
|
|||||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder)
|
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", openrestyrender.PowStaticDirPlaceholder)
|
||||||
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder)
|
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, openrestyrender.LuaDirPlaceholder)
|
||||||
}
|
}
|
||||||
|
if pagesDir := m.pagesRuntimePath(); pagesDir != "" {
|
||||||
|
normalizedRoute = strings.ReplaceAll(normalizedRoute, pagesDir, openrestyrender.PagesDirPlaceholder)
|
||||||
|
}
|
||||||
files, err := m.readManagedSupportFiles()
|
files, err := m.readManagedSupportFiles()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1130,6 +1134,9 @@ func (m *Manager) renderRouteConfig(content string) string {
|
|||||||
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
|
||||||
rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static")
|
rendered = strings.ReplaceAll(rendered, openrestyrender.PowStaticDirPlaceholder, luaDir+"/pow/static")
|
||||||
}
|
}
|
||||||
|
if pagesDir := m.pagesRuntimePath(); pagesDir != "" {
|
||||||
|
rendered = strings.ReplaceAll(rendered, openrestyrender.PagesDirPlaceholder, pagesDir)
|
||||||
|
}
|
||||||
return rendered
|
return rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1258,6 +1265,10 @@ func (m *Manager) luaRuntimePath() string {
|
|||||||
return filepath.ToSlash(m.NginxLuaDir)
|
return filepath.ToSlash(m.NginxLuaDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) pagesRuntimePath() string {
|
||||||
|
return filepath.ToSlash(strings.TrimSpace(m.PagesDir))
|
||||||
|
}
|
||||||
|
|
||||||
func checksum(content string) string {
|
func checksum(content string) string {
|
||||||
sum := sha256.Sum256([]byte(content))
|
sum := sha256.Sum256([]byte(content))
|
||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"openflare-agent/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pagesSourceDocument struct {
|
||||||
|
Routes []pagesSourceRoute `json:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pagesSourceRoute struct {
|
||||||
|
UpstreamType string `json:"upstream_type"`
|
||||||
|
PagesDeployment *pagesDeploymentSource `json:"pages_deployment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pagesDeploymentSource struct {
|
||||||
|
DeploymentID uint `json:"deployment_id"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pagesDeploymentMarker struct {
|
||||||
|
DeploymentID uint `json:"deployment_id"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncPagesDeployments(ctx context.Context, config *protocol.ActiveConfigResponse) error {
|
||||||
|
deployments, err := referencedPagesDeployments(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(deployments) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.pagesDir) == "" {
|
||||||
|
return errors.New("pages_dir is required when active config references Pages deployments")
|
||||||
|
}
|
||||||
|
for _, deployment := range deployments {
|
||||||
|
if err := s.ensurePagesDeployment(ctx, deployment); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensurePagesDeployment(ctx context.Context, deployment pagesDeploymentSource) error {
|
||||||
|
currentDir := pagesCurrentDir(s.pagesDir, deployment.DeploymentID)
|
||||||
|
if markerMatches(currentDir, deployment) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
packageBytes, err := s.client.DownloadPagesDeploymentPackage(ctx, deployment.DeploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("download Pages deployment %d: %w", deployment.DeploymentID, err)
|
||||||
|
}
|
||||||
|
if got := checksumBytes(packageBytes); got != deployment.Checksum {
|
||||||
|
return fmt.Errorf("Pages deployment %d checksum mismatch: expected %s, got %s", deployment.DeploymentID, deployment.Checksum, got)
|
||||||
|
}
|
||||||
|
releaseDir := pagesReleaseDir(s.pagesDir, deployment.DeploymentID, deployment.Checksum)
|
||||||
|
if !markerMatches(releaseDir, deployment) {
|
||||||
|
if err := extractPagesPackage(packageBytes, releaseDir, deployment); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return switchPagesCurrentDir(s.pagesDir, deployment.DeploymentID, releaseDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func referencedPagesDeployments(config *protocol.ActiveConfigResponse) ([]pagesDeploymentSource, error) {
|
||||||
|
if config == nil || strings.TrimSpace(config.SourceConfigJSON) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var doc pagesSourceDocument
|
||||||
|
if err := json.Unmarshal([]byte(config.SourceConfigJSON), &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode Pages references: %w", err)
|
||||||
|
}
|
||||||
|
seen := make(map[uint]struct{})
|
||||||
|
result := make([]pagesDeploymentSource, 0)
|
||||||
|
for _, route := range doc.Routes {
|
||||||
|
if strings.ToLower(strings.TrimSpace(route.UpstreamType)) != "pages" || route.PagesDeployment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deploymentID := route.PagesDeployment.DeploymentID
|
||||||
|
checksum := strings.TrimSpace(route.PagesDeployment.Checksum)
|
||||||
|
if deploymentID == 0 || checksum == "" {
|
||||||
|
return nil, errors.New("Pages deployment snapshot is incomplete")
|
||||||
|
}
|
||||||
|
if _, ok := seen[deploymentID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[deploymentID] = struct{}{}
|
||||||
|
result = append(result, pagesDeploymentSource{DeploymentID: deploymentID, Checksum: checksum})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCommonRootPrefix(files []*zip.File) (string, error) {
|
||||||
|
var firstFilePath string
|
||||||
|
hasMultipleFiles := false
|
||||||
|
for _, item := range files {
|
||||||
|
relativePath, skip, err := normalizePagesArchivePath(item.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalizedPath := filepath.ToSlash(relativePath)
|
||||||
|
if firstFilePath == "" {
|
||||||
|
firstFilePath = normalizedPath
|
||||||
|
} else {
|
||||||
|
hasMultipleFiles = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstFilePath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(firstFilePath, "/")
|
||||||
|
if len(parts) <= 1 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
commonPrefix := parts[0] + "/"
|
||||||
|
if hasMultipleFiles {
|
||||||
|
for _, item := range files {
|
||||||
|
relativePath, skip, err := normalizePagesArchivePath(item.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalizedPath := filepath.ToSlash(relativePath)
|
||||||
|
if !strings.HasPrefix(normalizedPath, commonPrefix) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commonPrefix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPagesPackage(packageBytes []byte, releaseDir string, deployment pagesDeploymentSource) error {
|
||||||
|
tmpDir := releaseDir + ".tmp"
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reader, err := zip.NewReader(bytes.NewReader(packageBytes), int64(len(packageBytes)))
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("open Pages zip: %w", err)
|
||||||
|
}
|
||||||
|
commonPrefix, err := findCommonRootPrefix(reader.File)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range reader.File {
|
||||||
|
relativePath, skip, err := normalizePagesArchivePath(item.Name)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if commonPrefix != "" {
|
||||||
|
slashPath := filepath.ToSlash(relativePath)
|
||||||
|
if strings.HasPrefix(slashPath, commonPrefix) {
|
||||||
|
relativePath = filepath.FromSlash(strings.TrimPrefix(slashPath, commonPrefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return fmt.Errorf("Pages package contains unsupported symlink: %s", relativePath)
|
||||||
|
}
|
||||||
|
if err := extractPagesFile(item, filepath.Join(tmpDir, relativePath)); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writePagesMarker(tmpDir, deployment); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(releaseDir)
|
||||||
|
return os.Rename(tmpDir, releaseDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPagesFile(item *zip.File, targetPath string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
source, err := item.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, item.FileInfo().Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
_, err = io.Copy(target, source)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchPagesCurrentDir(baseDir string, deploymentID uint, releaseDir string) error {
|
||||||
|
currentDir := pagesCurrentDir(baseDir, deploymentID)
|
||||||
|
previousDir := currentDir + ".previous"
|
||||||
|
_ = os.RemoveAll(previousDir)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(currentDir), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(currentDir); err == nil {
|
||||||
|
if err := os.Rename(currentDir, previousDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := copyPagesDir(releaseDir, currentDir); err != nil {
|
||||||
|
_ = os.RemoveAll(currentDir)
|
||||||
|
if _, restoreErr := os.Stat(previousDir); restoreErr == nil {
|
||||||
|
_ = os.Rename(previousDir, currentDir)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(previousDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPagesDir(sourceDir string, targetDir string) error {
|
||||||
|
return filepath.WalkDir(sourceDir, func(sourcePath string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relativePath, err := filepath.Rel(sourceDir, sourcePath)
|
||||||
|
if err != nil || relativePath == "." {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
targetPath := filepath.Join(targetDir, relativePath)
|
||||||
|
if entry.IsDir() {
|
||||||
|
return os.MkdirAll(targetPath, 0o755)
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
input, err := os.Open(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer input.Close()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
output, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode().Perm())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer output.Close()
|
||||||
|
_, err = io.Copy(output, input)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePagesArchivePath(raw string) (string, bool, error) {
|
||||||
|
name := strings.TrimSpace(filepath.ToSlash(raw))
|
||||||
|
if name == "" || strings.HasSuffix(name, "/") {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "/") {
|
||||||
|
return "", false, fmt.Errorf("Pages package contains absolute path: %s", raw)
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(name)
|
||||||
|
if cleaned == "." {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
|
||||||
|
return "", false, fmt.Errorf("Pages package path escapes deployment root: %s", raw)
|
||||||
|
}
|
||||||
|
return filepath.FromSlash(cleaned), false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func markerMatches(dir string, deployment pagesDeploymentSource) bool {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, ".openflare-pages.json"))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var marker pagesDeploymentMarker
|
||||||
|
if err := json.Unmarshal(data, &marker); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return marker.DeploymentID == deployment.DeploymentID && marker.Checksum == deployment.Checksum
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePagesMarker(dir string, deployment pagesDeploymentSource) error {
|
||||||
|
data, err := json.Marshal(pagesDeploymentMarker{
|
||||||
|
DeploymentID: deployment.DeploymentID,
|
||||||
|
Checksum: deployment.Checksum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(dir, ".openflare-pages.json"), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesCurrentDir(baseDir string, deploymentID uint) string {
|
||||||
|
return filepath.Join(baseDir, "deployments", fmt.Sprintf("%d", deploymentID), "current")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesReleaseDir(baseDir string, deploymentID uint, checksum string) string {
|
||||||
|
return filepath.Join(baseDir, "deployments", fmt.Sprintf("%d", deploymentID), "releases", checksum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checksumBytes(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
|
|
||||||
type ConfigClient interface {
|
type ConfigClient interface {
|
||||||
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
|
GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error)
|
||||||
|
DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error)
|
||||||
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
|
ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error
|
||||||
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
|
SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error)
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,11 @@ type Service struct {
|
|||||||
client ConfigClient
|
client ConfigClient
|
||||||
nginxManager NginxManager
|
nginxManager NginxManager
|
||||||
stateStore *state.Store
|
stateStore *state.Store
|
||||||
|
pagesDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetPagesDir(path string) {
|
||||||
|
s.pagesDir = strings.TrimSpace(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(client ConfigClient, nginxManager NginxManager, stateStore *state.Store) *Service {
|
func New(client ConfigClient, nginxManager NginxManager, stateStore *state.Store) *Service {
|
||||||
@@ -225,6 +231,9 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := s.syncPagesDeployments(ctx, config); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
mainConfigChecksum := checksumString(rendered.mainConfig)
|
mainConfigChecksum := checksumString(rendered.mainConfig)
|
||||||
routeConfigChecksum := checksumString(rendered.routeConfig)
|
routeConfigChecksum := checksumString(rendered.routeConfig)
|
||||||
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
|
slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum)
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,10 +23,15 @@ type fakeExecutor struct {
|
|||||||
reloadErr error
|
reloadErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPagesSourceConfigJSON(deploymentID uint, checksum string) string {
|
||||||
|
return fmt.Sprintf(`{"routes":[{"id":1,"site_name":"pages","domain":"pages.example.com","domains":["pages.example.com"],"origin_url":"openflare-pages://project/1","upstreams":["openflare-pages://project/1"],"enabled":true,"upstream_type":"pages","pages_deployment":{"project_id":1,"project_slug":"pages","deployment_id":%d,"deployment_number":1,"checksum":"%s","entry_file":"index.html","spa_fallback_enabled":true,"local_root":"__OPENFLARE_PAGES_DIR__/deployments/%d/current"}}],"openresty_config":{"worker_processes":"auto","worker_connections":1024,"worker_rlimit_nofile":65535,"events_multi_accept_enabled":true,"keepalive_timeout":20,"keepalive_requests":1000,"client_header_timeout":15,"client_body_timeout":15,"client_max_body_size":"64m","large_client_header_buffers":"4 16k","send_timeout":30,"proxy_connect_timeout":3,"proxy_send_timeout":60,"proxy_read_timeout":60,"websocket_enabled":true,"proxy_request_buffering":false,"proxy_buffering_enabled":true,"proxy_buffers":"16 16k","proxy_buffer_size":"8k","proxy_busy_buffers_size":"64k","gzip_enabled":true,"gzip_min_length":1024,"gzip_comp_level":5,"cache_enabled":false,"cache_levels":"1:2","cache_inactive":"30m","cache_max_size":"1g","cache_key_template":"$scheme$host$request_uri","cache_lock_enabled":true,"cache_lock_timeout":"5s","cache_use_stale":"error timeout updating http_500 http_502 http_503 http_504","main_config_template":"worker_processes {{OpenRestyWorkerProcesses}};"},"waf":{"rule_groups":[],"bindings":[]}}`, deploymentID, checksum, deploymentID)
|
||||||
|
}
|
||||||
|
|
||||||
type fakeClient struct {
|
type fakeClient struct {
|
||||||
config protocol.ActiveConfigResponse
|
config protocol.ActiveConfigResponse
|
||||||
reports []protocol.ApplyLogPayload
|
reports []protocol.ApplyLogPayload
|
||||||
fetchCalls int
|
pagesPackages map[uint][]byte
|
||||||
|
fetchCalls int
|
||||||
}
|
}
|
||||||
|
|
||||||
type fakeManager struct {
|
type fakeManager struct {
|
||||||
@@ -67,6 +76,13 @@ func (f *fakeClient) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfi
|
|||||||
return &f.config, nil
|
return &f.config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) {
|
||||||
|
if f.pagesPackages == nil {
|
||||||
|
return nil, fmt.Errorf("missing Pages package %d", deploymentID)
|
||||||
|
}
|
||||||
|
return f.pagesPackages[deploymentID], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error {
|
func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error {
|
||||||
f.reports = append(f.reports, payload)
|
f.reports = append(f.reports, payload)
|
||||||
return nil
|
return nil
|
||||||
@@ -179,6 +195,77 @@ func TestSyncOnceSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSyncOnceDownloadsPagesDeploymentBeforeApply(t *testing.T) {
|
||||||
|
packageBytes := testPagesPackage(t, map[string]string{"index.html": "hello"})
|
||||||
|
checksum := testBytesChecksum(packageBytes)
|
||||||
|
client := &fakeClient{
|
||||||
|
config: protocol.ActiveConfigResponse{
|
||||||
|
Version: "20260309-101",
|
||||||
|
Checksum: "pages-config-checksum",
|
||||||
|
SourceConfigJSON: testPagesSourceConfigJSON(7, checksum),
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
pagesPackages: map[uint][]byte{7: packageBytes},
|
||||||
|
}
|
||||||
|
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||||
|
nodeID, err := stateStore.EnsureNodeID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||||
|
}
|
||||||
|
snapshot, _ := stateStore.Load()
|
||||||
|
snapshot.NodeID = nodeID
|
||||||
|
if err = stateStore.Save(snapshot); err != nil {
|
||||||
|
t.Fatalf("save state failed: %v", err)
|
||||||
|
}
|
||||||
|
manager := &fakeManager{currentChecksum: "old-checksum"}
|
||||||
|
service := New(client, manager, stateStore)
|
||||||
|
pagesDir := t.TempDir()
|
||||||
|
service.SetPagesDir(pagesDir)
|
||||||
|
|
||||||
|
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-101", Checksum: "pages-config-checksum"}); err != nil {
|
||||||
|
t.Fatalf("SyncOnce failed: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "7", "current", "index.html"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected Pages file to be extracted: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "hello" {
|
||||||
|
t.Fatalf("unexpected Pages file content: %s", string(data))
|
||||||
|
}
|
||||||
|
if len(manager.applyRouteContents) != 1 || !strings.Contains(manager.applyRouteContents[0], "__OPENFLARE_PAGES_DIR__/deployments/7/current") {
|
||||||
|
t.Fatalf("expected Pages placeholder in rendered route config, got %#v", manager.applyRouteContents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncOnceRejectsPagesZipSlipBeforeApply(t *testing.T) {
|
||||||
|
packageBytes := testPagesPackage(t, map[string]string{"../escape.html": "bad", "index.html": "ok"})
|
||||||
|
checksum := testBytesChecksum(packageBytes)
|
||||||
|
client := &fakeClient{
|
||||||
|
config: protocol.ActiveConfigResponse{
|
||||||
|
Version: "20260309-102",
|
||||||
|
Checksum: "pages-config-checksum",
|
||||||
|
SourceConfigJSON: testPagesSourceConfigJSON(8, checksum),
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
pagesPackages: map[uint][]byte{8: packageBytes},
|
||||||
|
}
|
||||||
|
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||||
|
if _, err := stateStore.EnsureNodeID(); err != nil {
|
||||||
|
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||||
|
}
|
||||||
|
manager := &fakeManager{currentChecksum: "old-checksum"}
|
||||||
|
service := New(client, manager, stateStore)
|
||||||
|
service.SetPagesDir(t.TempDir())
|
||||||
|
|
||||||
|
err := service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-102", Checksum: "pages-config-checksum"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "escapes deployment root") {
|
||||||
|
t.Fatalf("expected zip-slip rejection, got %v", err)
|
||||||
|
}
|
||||||
|
if len(manager.applyRouteContents) != 0 {
|
||||||
|
t.Fatalf("OpenResty apply must not run after Pages package rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
func TestSyncOnceRollbackOnNginxFailure(t *testing.T) {
|
||||||
client := &fakeClient{
|
client := &fakeClient{
|
||||||
config: protocol.ActiveConfigResponse{
|
config: protocol.ActiveConfigResponse{
|
||||||
@@ -761,3 +848,76 @@ func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) {
|
|||||||
t.Fatal("expected no apply log when no config change is needed")
|
t.Fatal("expected no apply log when no config change is needed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPagesPackage(t *testing.T, files map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&buffer)
|
||||||
|
for name, content := range files {
|
||||||
|
file, err := writer.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip file failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("write zip file failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close zip failed: %v", err)
|
||||||
|
}
|
||||||
|
return buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBytesChecksum(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncOnceDownloadsPagesDeploymentWithTopLevelFolder(t *testing.T) {
|
||||||
|
packageBytes := testPagesPackage(t, map[string]string{
|
||||||
|
"Speed-Test-source/index.html": "hello html",
|
||||||
|
"Speed-Test-source/assets/app.js": "hello js",
|
||||||
|
})
|
||||||
|
checksum := testBytesChecksum(packageBytes)
|
||||||
|
client := &fakeClient{
|
||||||
|
config: protocol.ActiveConfigResponse{
|
||||||
|
Version: "20260309-105",
|
||||||
|
Checksum: "pages-config-checksum",
|
||||||
|
SourceConfigJSON: testPagesSourceConfigJSON(77, checksum),
|
||||||
|
CreatedAt: time.Now().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
pagesPackages: map[uint][]byte{77: packageBytes},
|
||||||
|
}
|
||||||
|
stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json"))
|
||||||
|
nodeID, err := stateStore.EnsureNodeID()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureNodeID failed: %v", err)
|
||||||
|
}
|
||||||
|
snapshot, _ := stateStore.Load()
|
||||||
|
snapshot.NodeID = nodeID
|
||||||
|
if err = stateStore.Save(snapshot); err != nil {
|
||||||
|
t.Fatalf("save state failed: %v", err)
|
||||||
|
}
|
||||||
|
manager := &fakeManager{currentChecksum: "old-checksum"}
|
||||||
|
service := New(client, manager, stateStore)
|
||||||
|
pagesDir := t.TempDir()
|
||||||
|
service.SetPagesDir(pagesDir)
|
||||||
|
|
||||||
|
if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{Version: "20260309-105", Checksum: "pages-config-checksum"}); err != nil {
|
||||||
|
t.Fatalf("SyncOnce failed: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "77", "current", "index.html"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected Pages index.html file to be extracted: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "hello html" {
|
||||||
|
t.Fatalf("unexpected Pages index.html content: %s", string(data))
|
||||||
|
}
|
||||||
|
jsData, err := os.ReadFile(filepath.Join(pagesDir, "deployments", "77", "current", "assets", "app.js"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected Pages assets/app.js file to be extracted: %v", err)
|
||||||
|
}
|
||||||
|
if string(jsData) != "hello js" {
|
||||||
|
t.Fatalf("unexpected Pages assets/app.js content: %s", string(jsData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"openflare/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ListPagesProjects(c *gin.Context) {
|
||||||
|
projects, err := service.ListPagesProjects()
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, projects)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPagesProject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := service.GetPagesProject(id)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePagesProject(c *gin.Context) {
|
||||||
|
var input service.PagesProjectInput
|
||||||
|
if !bindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := service.CreatePagesProject(input)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePagesProject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.PagesProjectInput
|
||||||
|
if !bindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := service.UpdatePagesProject(id, input)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePagesProject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.DeletePagesProject(id); err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesDeployments(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deployments, err := service.ListPagesProjectDeployments(id)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, deployments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadPagesDeployment(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := c.FormFile("package")
|
||||||
|
if err != nil {
|
||||||
|
respondBadRequest(c, "缺少 Pages 部署包")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deployment, err := service.UploadPagesDeployment(id, file, c.PostForm("entry_file"), c.GetString("username"))
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, deployment)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivatePagesDeployment(c *gin.Context) {
|
||||||
|
projectID, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deploymentID, ok := parseIDParamByName(c, "deployment_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
project, err := service.ActivatePagesDeployment(projectID, deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePagesDeployment(c *gin.Context) {
|
||||||
|
projectID, ok := parseIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deploymentID, ok := parseIDParamByName(c, "deployment_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.DeletePagesDeployment(projectID, deploymentID); err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesDeploymentFiles(c *gin.Context) {
|
||||||
|
deploymentID, ok := parseIDParamByName(c, "deployment_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files, err := service.ListPagesDeploymentFiles(deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondSuccess(c, files)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentDownloadPagesDeploymentPackage(c *gin.Context) {
|
||||||
|
deploymentID, ok := parseIDParamByName(c, "deployment_id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filePath, fileName, err := service.GetPagesDeploymentPackagePath(deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
respondFailure(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Disposition", "attachment; filename="+fileName)
|
||||||
|
c.File(filePath)
|
||||||
|
}
|
||||||
@@ -75,7 +75,11 @@ func decodeOptionalJSONBody(body io.Reader, target any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseIDParam(c *gin.Context) (uint, bool) {
|
func parseIDParam(c *gin.Context) (uint, bool) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
return parseIDParamByName(c, "id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIDParamByName(c *gin.Context, name string) (uint, bool) {
|
||||||
|
id, err := strconv.ParseUint(c.Param(name), 10, 64)
|
||||||
if err != nil || id == 0 {
|
if err != nil || id == 0 {
|
||||||
respondBadRequest(c, "")
|
respondBadRequest(c, "")
|
||||||
return 0, false
|
return 0, false
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package goose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
presslygoose "github.com/pressly/goose/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const versionPagesStaticHosting int64 = 202606030001
|
||||||
|
|
||||||
|
// migration202606030001 adds OpenFlare Pages static hosting tables and the
|
||||||
|
// proxy_routes.pages_project_id binding used by the global release snapshot.
|
||||||
|
func migration202606030001(backend string, ctx Context) *presslygoose.Migration {
|
||||||
|
return newGORMMigration(
|
||||||
|
versionPagesStaticHosting,
|
||||||
|
"202606030001_add_pages_static_hosting.go",
|
||||||
|
backend,
|
||||||
|
ctx,
|
||||||
|
migratePagesStaticHosting,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migratePagesStaticHosting(ctx Context, db *gorm.DB, backend string) error {
|
||||||
|
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec(
|
||||||
|
`UPDATE proxy_routes SET upstream_type = 'direct' WHERE upstream_type IS NULL OR TRIM(upstream_type) = ''`,
|
||||||
|
).Error; err != nil {
|
||||||
|
return fmt.Errorf("backfill proxy_routes.upstream_type: %w", err)
|
||||||
|
}
|
||||||
|
return validatePagesStaticHosting(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePagesStaticHosting(db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database handle is nil")
|
||||||
|
}
|
||||||
|
for _, table := range []string{"pages_projects", "pages_deployments", "pages_deployment_files"} {
|
||||||
|
if !db.Migrator().HasTable(table) {
|
||||||
|
return fmt.Errorf("table %s is missing", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, column := range []string{"upstream_type", "pages_project_id"} {
|
||||||
|
if !db.Migrator().HasColumn("proxy_routes", column) {
|
||||||
|
return fmt.Errorf("column proxy_routes.%s is missing", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, column := range []string{"slug", "active_deployment_id", "spa_fallback_enabled", "spa_fallback_path"} {
|
||||||
|
if !db.Migrator().HasColumn("pages_projects", column) {
|
||||||
|
return fmt.Errorf("column pages_projects.%s is missing", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, column := range []string{"project_id", "checksum", "artifact_path", "entry_file"} {
|
||||||
|
if !db.Migrator().HasColumn("pages_deployments", column) {
|
||||||
|
return fmt.Errorf("column pages_deployments.%s is missing", column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package goose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
presslygoose "github.com/pressly/goose/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const versionPagesSPAFallbackPath int64 = 202606030002
|
||||||
|
|
||||||
|
// migration202606030002 adds a configurable SPA fallback path for Pages
|
||||||
|
// projects. Existing projects keep the previous /index.html behavior.
|
||||||
|
func migration202606030002(backend string, ctx Context) *presslygoose.Migration {
|
||||||
|
return newGORMMigration(
|
||||||
|
versionPagesSPAFallbackPath,
|
||||||
|
"202606030002_add_pages_spa_fallback_path.go",
|
||||||
|
backend,
|
||||||
|
ctx,
|
||||||
|
migratePagesSPAFallbackPath,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migratePagesSPAFallbackPath(ctx Context, db *gorm.DB, backend string) error {
|
||||||
|
if err := ctx.ApplyCurrentSchema(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec(
|
||||||
|
`UPDATE pages_projects SET spa_fallback_path = '/index.html' WHERE spa_fallback_path IS NULL OR TRIM(spa_fallback_path) = ''`,
|
||||||
|
).Error; err != nil {
|
||||||
|
return fmt.Errorf("backfill pages_projects.spa_fallback_path: %w", err)
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn("pages_projects", "spa_fallback_path") {
|
||||||
|
return fmt.Errorf("column pages_projects.spa_fallback_path is missing")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -41,6 +41,8 @@ func newGORMMigration(version int64, source string, backend string, ctx Context,
|
|||||||
func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration {
|
func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration {
|
||||||
return []*presslygoose.Migration{
|
return []*presslygoose.Migration{
|
||||||
migration202606020001(backend, ctx),
|
migration202606020001(backend, ctx),
|
||||||
|
migration202606030001(backend, ctx),
|
||||||
|
migration202606030002(backend, ctx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ func registeredModels() []any {
|
|||||||
&Option{},
|
&Option{},
|
||||||
&Origin{},
|
&Origin{},
|
||||||
&ProxyRoute{},
|
&ProxyRoute{},
|
||||||
|
&PagesProject{},
|
||||||
|
&PagesDeployment{},
|
||||||
|
&PagesDeploymentFile{},
|
||||||
&ConfigVersion{},
|
&ConfigVersion{},
|
||||||
&Node{},
|
&Node{},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
PagesDeploymentStatusUploaded = "uploaded"
|
||||||
|
PagesDeploymentStatusActive = "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PagesProject struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Name string `json:"name" gorm:"size:255;not null"`
|
||||||
|
Slug string `json:"slug" gorm:"uniqueIndex;size:128;not null"`
|
||||||
|
Description string `json:"description" gorm:"type:text;not null;default:''"`
|
||||||
|
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||||
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled" gorm:"not null;default:false"`
|
||||||
|
SPAFallbackPath string `json:"spa_fallback_path" gorm:"size:512;not null;default:'/index.html'"`
|
||||||
|
ActiveDeploymentID *uint `json:"active_deployment_id" gorm:"index"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesDeployment struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
ProjectID uint `json:"project_id" gorm:"not null;index"`
|
||||||
|
DeploymentNumber int `json:"deployment_number" gorm:"not null"`
|
||||||
|
Checksum string `json:"checksum" gorm:"size:64;not null;index"`
|
||||||
|
Status string `json:"status" gorm:"size:32;not null;default:'uploaded';index"`
|
||||||
|
ArtifactPath string `json:"artifact_path" gorm:"size:2048;not null"`
|
||||||
|
FileCount int `json:"file_count" gorm:"not null;default:0"`
|
||||||
|
TotalSize int64 `json:"total_size" gorm:"not null;default:0"`
|
||||||
|
EntryFile string `json:"entry_file" gorm:"size:512;not null;default:'index.html'"`
|
||||||
|
CreatedBy string `json:"created_by" gorm:"size:64;not null;default:''"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ActivatedAt *time.Time `json:"activated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesDeploymentFile struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
DeploymentID uint `json:"deployment_id" gorm:"not null;index"`
|
||||||
|
Path string `json:"path" gorm:"size:2048;not null"`
|
||||||
|
Size int64 `json:"size" gorm:"not null;default:0"`
|
||||||
|
Checksum string `json:"checksum" gorm:"size:64;not null"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesProjects() (projects []*PagesProject, err error) {
|
||||||
|
err = DB.Order("id desc").Find(&projects).Error
|
||||||
|
return projects, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPagesProjectByID(id uint) (*PagesProject, error) {
|
||||||
|
project := &PagesProject{}
|
||||||
|
err := DB.First(project, id).Error
|
||||||
|
return project, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPagesProjectBySlug(slug string) (*PagesProject, error) {
|
||||||
|
project := &PagesProject{}
|
||||||
|
err := DB.Where("slug = ?", slug).First(project).Error
|
||||||
|
return project, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesDeployments(projectID uint) (deployments []*PagesDeployment, err error) {
|
||||||
|
err = DB.Where("project_id = ?", projectID).Order("id desc").Find(&deployments).Error
|
||||||
|
return deployments, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPagesDeploymentByID(id uint) (*PagesDeployment, error) {
|
||||||
|
deployment := &PagesDeployment{}
|
||||||
|
err := DB.First(deployment, id).Error
|
||||||
|
return deployment, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesDeploymentFiles(deploymentID uint) (files []*PagesDeploymentFile, err error) {
|
||||||
|
err = DB.Where("deployment_id = ?", deploymentID).Order("path asc").Find(&files).Error
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ type ProxyRoute struct {
|
|||||||
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
|
TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"`
|
||||||
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
|
TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"`
|
||||||
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
|
TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"`
|
||||||
|
PagesProjectID *uint `json:"pages_project_id" gorm:"index"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -95,6 +96,7 @@ func (route *ProxyRoute) Update() error {
|
|||||||
"tunnel_node_id": route.TunnelNodeID,
|
"tunnel_node_id": route.TunnelNodeID,
|
||||||
"tunnel_target_addr": route.TunnelTargetAddr,
|
"tunnel_target_addr": route.TunnelTargetAddr,
|
||||||
"tunnel_target_protocol": route.TunnelTargetProtocol,
|
"tunnel_target_protocol": route.TunnelTargetProtocol,
|
||||||
|
"pages_project_id": route.PagesProjectID,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
originRoute.POST("/:id/update", controller.UpdateOrigin)
|
originRoute.POST("/:id/update", controller.UpdateOrigin)
|
||||||
originRoute.POST("/:id/delete", controller.DeleteOrigin)
|
originRoute.POST("/:id/delete", controller.DeleteOrigin)
|
||||||
}
|
}
|
||||||
|
pagesRoute := apiRouter.Group("/pages")
|
||||||
|
pagesRoute.Use(middleware.AdminAuth())
|
||||||
|
{
|
||||||
|
pagesRoute.GET("/", controller.ListPagesProjects)
|
||||||
|
pagesRoute.GET("/:id", controller.GetPagesProject)
|
||||||
|
pagesRoute.POST("/", controller.CreatePagesProject)
|
||||||
|
pagesRoute.POST("/:id/update", controller.UpdatePagesProject)
|
||||||
|
pagesRoute.POST("/:id/delete", controller.DeletePagesProject)
|
||||||
|
pagesRoute.GET("/:id/deployments", controller.ListPagesDeployments)
|
||||||
|
pagesRoute.POST("/:id/deployments/upload", controller.UploadPagesDeployment)
|
||||||
|
pagesRoute.POST("/:id/deployments/:deployment_id/activate", controller.ActivatePagesDeployment)
|
||||||
|
pagesRoute.POST("/:id/deployments/:deployment_id/delete", controller.DeletePagesDeployment)
|
||||||
|
pagesRoute.GET("/deployments/:deployment_id/files", controller.ListPagesDeploymentFiles)
|
||||||
|
}
|
||||||
managedDomainRoute := apiRouter.Group("/managed-domains")
|
managedDomainRoute := apiRouter.Group("/managed-domains")
|
||||||
managedDomainRoute.Use(middleware.AdminAuth())
|
managedDomainRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
@@ -227,6 +241,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
authorizedRoute.GET("/ws", controller.AgentWebSocket)
|
authorizedRoute.GET("/ws", controller.AgentWebSocket)
|
||||||
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat)
|
||||||
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
authorizedRoute.GET("/config-versions/active", controller.AgentGetActiveConfig)
|
||||||
|
authorizedRoute.GET("/pages/deployments/:deployment_id/package", controller.AgentDownloadPagesDeploymentPackage)
|
||||||
authorizedRoute.POST("/waf/ip-groups/sync", controller.AgentSyncWAFIPGroups)
|
authorizedRoute.POST("/waf/ip-groups/sync", controller.AgentSyncWAFIPGroups)
|
||||||
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
authorizedRoute.POST("/apply-logs", controller.AgentReportApplyLog)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ type snapshotRoute struct {
|
|||||||
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
|
TunnelNodeID *uint `json:"tunnel_node_id,omitempty"`
|
||||||
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
|
TunnelTargetAddr string `json:"tunnel_target_addr,omitempty"`
|
||||||
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
|
TunnelTargetProto string `json:"tunnel_target_protocol,omitempty"`
|
||||||
|
PagesProjectID *uint `json:"pages_project_id,omitempty"`
|
||||||
|
PagesDeployment *snapshotPagesDeployment `json:"pages_deployment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotPagesDeployment struct {
|
||||||
|
ProjectID uint `json:"project_id"`
|
||||||
|
ProjectSlug string `json:"project_slug"`
|
||||||
|
DeploymentID uint `json:"deployment_id"`
|
||||||
|
DeploymentNumber int `json:"deployment_number"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
EntryFile string `json:"entry_file"`
|
||||||
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
LocalRoot string `json:"local_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type snapshotWAFRuleGroup struct {
|
type snapshotWAFRuleGroup struct {
|
||||||
@@ -515,12 +529,23 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
|||||||
var tunnelNodeID *uint
|
var tunnelNodeID *uint
|
||||||
var tunnelTargetAddr string
|
var tunnelTargetAddr string
|
||||||
var tunnelTargetProtocol string
|
var tunnelTargetProtocol string
|
||||||
|
var pagesProjectID *uint
|
||||||
|
var pagesDeployment *snapshotPagesDeployment
|
||||||
if upstreamType == "tunnel" {
|
if upstreamType == "tunnel" {
|
||||||
originURL = resolveTunnelOpenRestyUpstreamURL()
|
originURL = resolveTunnelOpenRestyUpstreamURL()
|
||||||
upstreams = []string{originURL}
|
upstreams = []string{originURL}
|
||||||
tunnelNodeID = route.TunnelNodeID
|
tunnelNodeID = route.TunnelNodeID
|
||||||
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
|
tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr)
|
||||||
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
|
tunnelTargetProtocol = normalizeTunnelTargetProtocol(route.TunnelTargetProtocol)
|
||||||
|
} else if upstreamType == "pages" {
|
||||||
|
deployment, err := buildSnapshotPagesDeployment(route.PagesProjectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("路由 %s Pages 配置无效: %w", route.Domain, err)
|
||||||
|
}
|
||||||
|
originURL = fmt.Sprintf("openflare-pages://project/%d", deployment.ProjectID)
|
||||||
|
upstreams = []string{originURL}
|
||||||
|
pagesProjectID = route.PagesProjectID
|
||||||
|
pagesDeployment = deployment
|
||||||
}
|
}
|
||||||
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
cacheRules, err := decodeStoredCacheRules(route.CacheRules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -564,11 +589,47 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
|
|||||||
TunnelNodeID: tunnelNodeID,
|
TunnelNodeID: tunnelNodeID,
|
||||||
TunnelTargetAddr: tunnelTargetAddr,
|
TunnelTargetAddr: tunnelTargetAddr,
|
||||||
TunnelTargetProto: tunnelTargetProtocol,
|
TunnelTargetProto: tunnelTargetProtocol,
|
||||||
|
PagesProjectID: pagesProjectID,
|
||||||
|
PagesDeployment: pagesDeployment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildSnapshotPagesDeployment(projectID *uint) (*snapshotPagesDeployment, error) {
|
||||||
|
if projectID == nil || *projectID == 0 {
|
||||||
|
return nil, errors.New("pages_project_id is required")
|
||||||
|
}
|
||||||
|
project, err := model.GetPagesProjectByID(*projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !project.Enabled {
|
||||||
|
return nil, errors.New("Pages 项目未启用")
|
||||||
|
}
|
||||||
|
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||||
|
return nil, errors.New("Pages 项目没有激活部署")
|
||||||
|
}
|
||||||
|
deployment, err := model.GetPagesDeploymentByID(*project.ActiveDeploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if deployment.ProjectID != project.ID {
|
||||||
|
return nil, errors.New("Pages 激活部署不属于当前项目")
|
||||||
|
}
|
||||||
|
return &snapshotPagesDeployment{
|
||||||
|
ProjectID: project.ID,
|
||||||
|
ProjectSlug: project.Slug,
|
||||||
|
DeploymentID: deployment.ID,
|
||||||
|
DeploymentNumber: deployment.DeploymentNumber,
|
||||||
|
Checksum: deployment.Checksum,
|
||||||
|
EntryFile: deployment.EntryFile,
|
||||||
|
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||||
|
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
||||||
|
LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func resolveTunnelOpenRestyUpstreamURL() string {
|
func resolveTunnelOpenRestyUpstreamURL() string {
|
||||||
relayNodes, err := model.ListNodesByType("tunnel_relay")
|
relayNodes, err := model.ListNodesByType("tunnel_relay")
|
||||||
if err == nil && len(relayNodes) > 0 {
|
if err == nil && len(relayNodes) > 0 {
|
||||||
@@ -819,10 +880,18 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
|
|||||||
if routes[index].UpstreamType == "tunnel" {
|
if routes[index].UpstreamType == "tunnel" {
|
||||||
routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr)
|
routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr)
|
||||||
routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto)
|
routes[index].TunnelTargetProto = normalizeTunnelTargetProtocol(routes[index].TunnelTargetProto)
|
||||||
|
routes[index].PagesProjectID = nil
|
||||||
|
routes[index].PagesDeployment = nil
|
||||||
|
} else if routes[index].UpstreamType == "pages" {
|
||||||
|
routes[index].TunnelNodeID = nil
|
||||||
|
routes[index].TunnelTargetAddr = ""
|
||||||
|
routes[index].TunnelTargetProto = ""
|
||||||
} else {
|
} else {
|
||||||
routes[index].TunnelNodeID = nil
|
routes[index].TunnelNodeID = nil
|
||||||
routes[index].TunnelTargetAddr = ""
|
routes[index].TunnelTargetAddr = ""
|
||||||
routes[index].TunnelTargetProto = ""
|
routes[index].TunnelTargetProto = ""
|
||||||
|
routes[index].PagesProjectID = nil
|
||||||
|
routes[index].PagesDeployment = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return routes
|
return routes
|
||||||
@@ -849,7 +918,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
|
|||||||
}
|
}
|
||||||
|
|
||||||
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
|
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
|
||||||
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
|
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || left.BasicAuthEnabled != right.BasicAuthEnabled || left.BasicAuthUsername != right.BasicAuthUsername || left.BasicAuthPassword != right.BasicAuthPassword || left.UpstreamType != right.UpstreamType || !uintPtrEqual(left.TunnelNodeID, right.TunnelNodeID) || left.TunnelTargetAddr != right.TunnelTargetAddr || left.TunnelTargetProto != right.TunnelTargetProto || !uintPtrEqual(left.PagesProjectID, right.PagesProjectID) || !snapshotPagesDeploymentEqual(left.PagesDeployment, right.PagesDeployment) || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if len(left.Domains) != len(right.Domains) {
|
if len(left.Domains) != len(right.Domains) {
|
||||||
@@ -890,6 +959,21 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func snapshotPagesDeploymentEqual(left *snapshotPagesDeployment, right *snapshotPagesDeployment) bool {
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return left == nil && right == nil
|
||||||
|
}
|
||||||
|
leftJSON, err := json.Marshal(left)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rightJSON, err := json.Marshal(right)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return string(leftJSON) == string(rightJSON)
|
||||||
|
}
|
||||||
|
|
||||||
func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument) bool {
|
func snapshotWAFConfigEqual(left snapshotWAFDocument, right snapshotWAFDocument) bool {
|
||||||
leftJSON, err := json.Marshal(left)
|
leftJSON, err := json.Marshal(left)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"openflare/common"
|
||||||
|
"openflare/model"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
pagesMaxDeploymentFiles = 1000
|
||||||
|
pagesMaxDeploymentBytes = 100 * 1024 * 1024
|
||||||
|
defaultPagesEntryFile = "index.html"
|
||||||
|
defaultPagesFallbackPath = "/index.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pagesSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,126}[a-z0-9]$|^[a-z0-9]$`)
|
||||||
|
|
||||||
|
type PagesProjectInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesProjectView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
ActiveDeploymentID *uint `json:"active_deployment_id"`
|
||||||
|
ActiveDeployment *PagesDeploymentView `json:"active_deployment,omitempty"`
|
||||||
|
DeploymentCount int64 `json:"deployment_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesDeploymentView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ProjectID uint `json:"project_id"`
|
||||||
|
DeploymentNumber int `json:"deployment_number"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
EntryFile string `json:"entry_file"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ActivatedAt *time.Time `json:"activated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesDeploymentFileView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
DeploymentID uint `json:"deployment_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type pagesDeploymentManifest struct {
|
||||||
|
Files []model.PagesDeploymentFile
|
||||||
|
FileCount int
|
||||||
|
TotalSize int64
|
||||||
|
EntryFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesProjects() ([]*PagesProjectView, error) {
|
||||||
|
projects, err := model.ListPagesProjects()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]*PagesProjectView, 0, len(projects))
|
||||||
|
for _, project := range projects {
|
||||||
|
view, err := buildPagesProjectView(project)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views = append(views, view)
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPagesProject(id uint) (*PagesProjectView, error) {
|
||||||
|
project, err := model.GetPagesProjectByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildPagesProjectView(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePagesProject(input PagesProjectInput) (*PagesProjectView, error) {
|
||||||
|
project, err := buildPagesProject(nil, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = model.DB.Create(project).Error; err != nil {
|
||||||
|
if model.IsUniqueConstraintError(err) {
|
||||||
|
return nil, errors.New("Pages 项目标识已存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildPagesProjectView(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePagesProject(id uint, input PagesProjectInput) (*PagesProjectView, error) {
|
||||||
|
project, err := model.GetPagesProjectByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
project, err = buildPagesProject(project, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = model.DB.Model(project).Updates(map[string]any{
|
||||||
|
"name": project.Name,
|
||||||
|
"slug": project.Slug,
|
||||||
|
"description": project.Description,
|
||||||
|
"enabled": project.Enabled,
|
||||||
|
"spa_fallback_enabled": project.SPAFallbackEnabled,
|
||||||
|
"spa_fallback_path": project.SPAFallbackPath,
|
||||||
|
}).Error; err != nil {
|
||||||
|
if model.IsUniqueConstraintError(err) {
|
||||||
|
return nil, errors.New("Pages 项目标识已存在")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildPagesProjectView(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePagesProject(id uint) error {
|
||||||
|
project, err := model.GetPagesProjectByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var routeCount int64
|
||||||
|
if err = model.DB.Model(&model.ProxyRoute{}).Where("pages_project_id = ?", project.ID).Count(&routeCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if routeCount > 0 {
|
||||||
|
return errors.New("Pages 项目已被规则引用,不能删除")
|
||||||
|
}
|
||||||
|
deployments, err := model.ListPagesDeployments(project.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("deployment_id IN (?)", tx.Model(&model.PagesDeployment{}).Select("id").Where("project_id = ?", project.ID)).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("project_id = ?", project.ID).Delete(&model.PagesDeployment{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Delete(project).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, deployment := range deployments {
|
||||||
|
_ = os.Remove(deployment.ArtifactPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesProjectDeployments(projectID uint) ([]*PagesDeploymentView, error) {
|
||||||
|
if _, err := model.GetPagesProjectByID(projectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deployments, err := model.ListPagesDeployments(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]*PagesDeploymentView, 0, len(deployments))
|
||||||
|
for _, deployment := range deployments {
|
||||||
|
views = append(views, buildPagesDeploymentView(deployment))
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPagesDeploymentFiles(deploymentID uint) ([]*PagesDeploymentFileView, error) {
|
||||||
|
if _, err := model.GetPagesDeploymentByID(deploymentID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files, err := model.ListPagesDeploymentFiles(deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]*PagesDeploymentFileView, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
views = append(views, &PagesDeploymentFileView{
|
||||||
|
ID: file.ID,
|
||||||
|
DeploymentID: file.DeploymentID,
|
||||||
|
Path: file.Path,
|
||||||
|
Size: file.Size,
|
||||||
|
Checksum: file.Checksum,
|
||||||
|
CreatedAt: file.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return views, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UploadPagesDeployment(projectID uint, fileHeader *multipart.FileHeader, entryFile string, createdBy string) (*PagesDeploymentView, error) {
|
||||||
|
project, err := model.GetPagesProjectByID(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if fileHeader == nil {
|
||||||
|
return nil, errors.New("缺少 Pages 部署包")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(filepath.Ext(fileHeader.Filename), ".zip") {
|
||||||
|
return nil, errors.New("Pages 部署包必须是 .zip 文件")
|
||||||
|
}
|
||||||
|
entryFile = normalizePagesEntryFile(entryFile)
|
||||||
|
tempPath, checksum, err := persistPagesUploadTemp(fileHeader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(tempPath)
|
||||||
|
manifest, err := inspectPagesZip(tempPath, entryFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
artifactPath, err := pagesArtifactPath(project.Slug, checksum)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = os.MkdirAll(filepath.Dir(artifactPath), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建 Pages 存储目录失败: %w", err)
|
||||||
|
}
|
||||||
|
if err = copyFile(tempPath, artifactPath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deployment := &model.PagesDeployment{}
|
||||||
|
err = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var maxNumber int
|
||||||
|
if err := tx.Model(&model.PagesDeployment{}).
|
||||||
|
Where("project_id = ?", project.ID).
|
||||||
|
Select("COALESCE(MAX(deployment_number), 0)").
|
||||||
|
Scan(&maxNumber).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deployment = &model.PagesDeployment{
|
||||||
|
ProjectID: project.ID,
|
||||||
|
DeploymentNumber: maxNumber + 1,
|
||||||
|
Checksum: checksum,
|
||||||
|
Status: model.PagesDeploymentStatusUploaded,
|
||||||
|
ArtifactPath: artifactPath,
|
||||||
|
FileCount: manifest.FileCount,
|
||||||
|
TotalSize: manifest.TotalSize,
|
||||||
|
EntryFile: manifest.EntryFile,
|
||||||
|
CreatedBy: strings.TrimSpace(createdBy),
|
||||||
|
}
|
||||||
|
if err := tx.Create(deployment).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for index := range manifest.Files {
|
||||||
|
manifest.Files[index].DeploymentID = deployment.ID
|
||||||
|
}
|
||||||
|
if len(manifest.Files) > 0 {
|
||||||
|
if err := tx.Create(&manifest.Files).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(artifactPath)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildPagesDeploymentView(deployment), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivatePagesDeployment(projectID uint, deploymentID uint) (*PagesProjectView, error) {
|
||||||
|
project, err := model.GetPagesProjectByID(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deployment, err := model.GetPagesDeploymentByID(deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if deployment.ProjectID != project.ID {
|
||||||
|
return nil, errors.New("Pages 部署不属于该项目")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&model.PagesDeployment{}).
|
||||||
|
Where("project_id = ?", project.ID).
|
||||||
|
Update("status", model.PagesDeploymentStatusUploaded).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(deployment).Updates(map[string]any{
|
||||||
|
"status": model.PagesDeploymentStatusActive,
|
||||||
|
"activated_at": &now,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(project).Updates(map[string]any{
|
||||||
|
"active_deployment_id": deployment.ID,
|
||||||
|
}).Error
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return GetPagesProject(project.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePagesDeployment(projectID uint, deploymentID uint) error {
|
||||||
|
project, err := model.GetPagesProjectByID(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deployment, err := model.GetPagesDeploymentByID(deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if deployment.ProjectID != project.ID {
|
||||||
|
return errors.New("Pages 部署不属于该项目")
|
||||||
|
}
|
||||||
|
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID == deployment.ID {
|
||||||
|
return errors.New("不能删除当前激活的 Pages 部署")
|
||||||
|
}
|
||||||
|
return model.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("deployment_id = ?", deployment.ID).Delete(&model.PagesDeploymentFile{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Delete(deployment).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = os.Remove(deployment.ArtifactPath)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPagesDeploymentPackagePath(deploymentID uint) (string, string, error) {
|
||||||
|
deployment, err := model.GetPagesDeploymentByID(deploymentID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if err = ensurePagesDeploymentInActiveSnapshot(deployment.ID); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(deployment.ArtifactPath) == "" {
|
||||||
|
return "", "", errors.New("Pages 部署包路径为空")
|
||||||
|
}
|
||||||
|
if _, err = os.Stat(deployment.ArtifactPath); err != nil {
|
||||||
|
return "", "", fmt.Errorf("Pages 部署包不存在: %w", err)
|
||||||
|
}
|
||||||
|
return deployment.ArtifactPath, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensurePagesDeploymentInActiveSnapshot(deploymentID uint) error {
|
||||||
|
version, err := model.GetActiveConfigVersion()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("Pages 部署尚未进入激活配置")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
snapshot, err := parseSnapshotDocument(version.SnapshotJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, route := range snapshot.Routes {
|
||||||
|
if route.UpstreamType != "pages" || route.PagesDeployment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if route.PagesDeployment.DeploymentID == deploymentID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("Pages 部署尚未进入激活配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPagesProject(project *model.PagesProject, input PagesProjectInput) (*model.PagesProject, error) {
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("Pages 项目名称不能为空")
|
||||||
|
}
|
||||||
|
slug := normalizePagesSlug(input.Slug)
|
||||||
|
if slug == "" {
|
||||||
|
slug = normalizePagesSlug(name)
|
||||||
|
}
|
||||||
|
if !pagesSlugPattern.MatchString(slug) {
|
||||||
|
return nil, errors.New("Pages 项目标识只能包含小写字母、数字和连字符")
|
||||||
|
}
|
||||||
|
if project == nil {
|
||||||
|
project = &model.PagesProject{}
|
||||||
|
}
|
||||||
|
project.Name = name
|
||||||
|
project.Slug = slug
|
||||||
|
project.Description = strings.TrimSpace(input.Description)
|
||||||
|
project.Enabled = input.Enabled
|
||||||
|
project.SPAFallbackEnabled = input.SPAFallbackEnabled
|
||||||
|
fallbackPath, err := normalizePagesFallbackPath(input.SPAFallbackPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
project.SPAFallbackPath = fallbackPath
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPagesProjectView(project *model.PagesProject) (*PagesProjectView, error) {
|
||||||
|
if project == nil {
|
||||||
|
return nil, errors.New("Pages 项目为空")
|
||||||
|
}
|
||||||
|
view := &PagesProjectView{
|
||||||
|
ID: project.ID,
|
||||||
|
Name: project.Name,
|
||||||
|
Slug: project.Slug,
|
||||||
|
Description: project.Description,
|
||||||
|
Enabled: project.Enabled,
|
||||||
|
SPAFallbackEnabled: project.SPAFallbackEnabled,
|
||||||
|
SPAFallbackPath: normalizeStoredPagesFallbackPath(project.SPAFallbackPath),
|
||||||
|
ActiveDeploymentID: project.ActiveDeploymentID,
|
||||||
|
CreatedAt: project.CreatedAt,
|
||||||
|
UpdatedAt: project.UpdatedAt,
|
||||||
|
}
|
||||||
|
if err := model.DB.Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&view.DeploymentCount).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if project.ActiveDeploymentID != nil && *project.ActiveDeploymentID != 0 {
|
||||||
|
deployment, err := model.GetPagesDeploymentByID(*project.ActiveDeploymentID)
|
||||||
|
if err == nil {
|
||||||
|
view.ActiveDeployment = buildPagesDeploymentView(deployment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPagesDeploymentView(deployment *model.PagesDeployment) *PagesDeploymentView {
|
||||||
|
if deployment == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &PagesDeploymentView{
|
||||||
|
ID: deployment.ID,
|
||||||
|
ProjectID: deployment.ProjectID,
|
||||||
|
DeploymentNumber: deployment.DeploymentNumber,
|
||||||
|
Checksum: deployment.Checksum,
|
||||||
|
Status: deployment.Status,
|
||||||
|
FileCount: deployment.FileCount,
|
||||||
|
TotalSize: deployment.TotalSize,
|
||||||
|
EntryFile: deployment.EntryFile,
|
||||||
|
CreatedBy: deployment.CreatedBy,
|
||||||
|
CreatedAt: deployment.CreatedAt,
|
||||||
|
ActivatedAt: deployment.ActivatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePagesSlug(raw string) string {
|
||||||
|
value := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
var builder strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range value {
|
||||||
|
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||||
|
if valid {
|
||||||
|
builder.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastDash {
|
||||||
|
builder.WriteByte('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(builder.String(), "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePagesFallbackPath(raw string) (string, error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
if value == "" {
|
||||||
|
value = defaultPagesFallbackPath
|
||||||
|
}
|
||||||
|
if len(value) > 512 {
|
||||||
|
return "", errors.New("SPA fallback 回退路径长度不能超过 512")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, "/") {
|
||||||
|
return "", errors.New("SPA fallback 回退路径必须以 / 开头")
|
||||||
|
}
|
||||||
|
if value == "/" || strings.HasSuffix(value, "/") {
|
||||||
|
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
|
||||||
|
}
|
||||||
|
if strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") {
|
||||||
|
return "", errors.New("SPA fallback 回退路径包含不支持的字符")
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
if r <= 0x20 || r == 0x7f {
|
||||||
|
return "", errors.New("SPA fallback 回退路径不能包含空白或控制字符")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, segment := range strings.Split(value, "/") {
|
||||||
|
if segment == "." || segment == ".." {
|
||||||
|
return "", errors.New("SPA fallback 回退路径不能包含 . 或 .. 路径段")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(value)
|
||||||
|
if cleaned == "." || !strings.HasPrefix(cleaned, "/") {
|
||||||
|
return "", errors.New("SPA fallback 回退路径不合法")
|
||||||
|
}
|
||||||
|
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
|
||||||
|
return "", errors.New("SPA fallback 回退路径必须指向具体文件")
|
||||||
|
}
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStoredPagesFallbackPath(value string) string {
|
||||||
|
normalized, err := normalizePagesFallbackPath(value)
|
||||||
|
if err != nil {
|
||||||
|
return defaultPagesFallbackPath
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePagesEntryFile(raw string) string {
|
||||||
|
value := path.Clean(strings.TrimSpace(filepath.ToSlash(raw)))
|
||||||
|
if value == "." || value == "/" {
|
||||||
|
return defaultPagesEntryFile
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(value, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistPagesUploadTemp(fileHeader *multipart.FileHeader) (string, string, error) {
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
temp, err := os.CreateTemp("", "openflare-pages-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer temp.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
limited := io.LimitReader(file, pagesMaxDeploymentBytes+1)
|
||||||
|
written, err := io.Copy(io.MultiWriter(temp, hash), limited)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(temp.Name())
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if written > pagesMaxDeploymentBytes {
|
||||||
|
_ = os.Remove(temp.Name())
|
||||||
|
return "", "", fmt.Errorf("Pages 部署包不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
|
||||||
|
}
|
||||||
|
return temp.Name(), hex.EncodeToString(hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCommonRootPrefix(files []*zip.File) (string, error) {
|
||||||
|
var firstFilePath string
|
||||||
|
hasMultipleFiles := false
|
||||||
|
for _, item := range files {
|
||||||
|
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if firstFilePath == "" {
|
||||||
|
firstFilePath = normalizedPath
|
||||||
|
} else {
|
||||||
|
hasMultipleFiles = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstFilePath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(firstFilePath, "/")
|
||||||
|
if len(parts) <= 1 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
commonPrefix := parts[0] + "/"
|
||||||
|
if hasMultipleFiles {
|
||||||
|
for _, item := range files {
|
||||||
|
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(normalizedPath, commonPrefix) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return commonPrefix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest, error) {
|
||||||
|
reader, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("Pages 部署包不是有效 zip 文件")
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
commonPrefix, err := findCommonRootPrefix(reader.File)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := &pagesDeploymentManifest{
|
||||||
|
Files: []model.PagesDeploymentFile{},
|
||||||
|
EntryFile: entryFile,
|
||||||
|
}
|
||||||
|
entrySeen := false
|
||||||
|
for _, item := range reader.File {
|
||||||
|
normalizedPath, skip, err := normalizePagesZipPath(item.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if commonPrefix != "" {
|
||||||
|
normalizedPath = strings.TrimPrefix(normalizedPath, commonPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||||
|
return nil, fmt.Errorf("Pages 部署包不支持符号链接: %s", normalizedPath)
|
||||||
|
}
|
||||||
|
if item.UncompressedSize64 > pagesMaxDeploymentBytes {
|
||||||
|
return nil, fmt.Errorf("Pages 文件过大: %s", normalizedPath)
|
||||||
|
}
|
||||||
|
manifest.FileCount++
|
||||||
|
if manifest.FileCount > pagesMaxDeploymentFiles {
|
||||||
|
return nil, fmt.Errorf("Pages 部署文件数不能超过 %d", pagesMaxDeploymentFiles)
|
||||||
|
}
|
||||||
|
manifest.TotalSize += int64(item.UncompressedSize64)
|
||||||
|
if manifest.TotalSize > pagesMaxDeploymentBytes {
|
||||||
|
return nil, fmt.Errorf("Pages 部署展开后不能超过 %d MiB", pagesMaxDeploymentBytes/1024/1024)
|
||||||
|
}
|
||||||
|
checksum, err := checksumZipFile(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if normalizedPath == entryFile {
|
||||||
|
entrySeen = true
|
||||||
|
}
|
||||||
|
manifest.Files = append(manifest.Files, model.PagesDeploymentFile{
|
||||||
|
Path: normalizedPath,
|
||||||
|
Size: int64(item.UncompressedSize64),
|
||||||
|
Checksum: checksum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if manifest.FileCount == 0 {
|
||||||
|
return nil, errors.New("Pages 部署包不能为空")
|
||||||
|
}
|
||||||
|
if !entrySeen {
|
||||||
|
return nil, fmt.Errorf("Pages 部署包缺少入口文件 %s", entryFile)
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePagesZipPath(raw string) (string, bool, error) {
|
||||||
|
name := strings.TrimSpace(filepath.ToSlash(raw))
|
||||||
|
if name == "" {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(name, "/") {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "/") || path.IsAbs(name) {
|
||||||
|
return "", false, fmt.Errorf("Pages 部署包不能包含绝对路径: %s", raw)
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(name)
|
||||||
|
if cleaned == "." {
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
if cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.Contains(cleaned, "/../") {
|
||||||
|
return "", false, fmt.Errorf("Pages 部署包路径不能逃逸目录: %s", raw)
|
||||||
|
}
|
||||||
|
return cleaned, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checksumZipFile(item *zip.File) (string, error) {
|
||||||
|
file, err := item.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
hash := sha256.New()
|
||||||
|
if _, err = io.Copy(hash, file); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesArtifactPath(projectSlug string, checksum string) (string, error) {
|
||||||
|
root, err := pagesStorageRoot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(root, "artifacts", projectSlug, checksum+".zip"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesStorageRoot() (string, error) {
|
||||||
|
if common.SQLDSN != "" {
|
||||||
|
return filepath.Abs(filepath.Join("data", "pages"))
|
||||||
|
}
|
||||||
|
dbPath := strings.TrimSpace(common.SQLitePath)
|
||||||
|
if dbPath == "" {
|
||||||
|
return filepath.Abs(filepath.Join("data", "pages"))
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(dbPath)
|
||||||
|
if dir == "." || dir == "" {
|
||||||
|
dir = "data"
|
||||||
|
}
|
||||||
|
return filepath.Abs(filepath.Join(dir, "pages"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src string, dst string) error {
|
||||||
|
input, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer input.Close()
|
||||||
|
output, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer output.Close()
|
||||||
|
if _, err = io.Copy(output, input); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return output.Sync()
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http/httptest"
|
||||||
|
"openflare/model"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPagesUploadActivateAndPublishStaticRoute(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "Marketing Site",
|
||||||
|
Slug: "marketing-site",
|
||||||
|
Enabled: true,
|
||||||
|
SPAFallbackEnabled: true,
|
||||||
|
SPAFallbackPath: "/app.html",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||||
|
}
|
||||||
|
uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
|
||||||
|
"index.html": "<h1>Hello Pages</h1>",
|
||||||
|
"assets/app.js": "console.log('pages')",
|
||||||
|
"assets/style.css": "body{color:#111}",
|
||||||
|
}))
|
||||||
|
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPagesDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if deployment.FileCount != 3 || deployment.TotalSize == 0 {
|
||||||
|
t.Fatalf("unexpected deployment manifest: %+v", deployment)
|
||||||
|
}
|
||||||
|
project, err = ActivatePagesDeployment(project.ID, deployment.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ActivatePagesDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != deployment.ID {
|
||||||
|
t.Fatalf("expected active deployment %d, got %+v", deployment.ID, project.ActiveDeploymentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||||
|
Domain: "pages.example.com",
|
||||||
|
Enabled: true,
|
||||||
|
UpstreamType: "pages",
|
||||||
|
PagesProjectID: &project.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||||
|
}
|
||||||
|
if route.UpstreamType != "pages" || route.PagesProjectID == nil || *route.PagesProjectID != project.ID {
|
||||||
|
t.Fatalf("expected route to bind Pages project, got %+v", route)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := PublishConfigVersion("root", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Version.SnapshotJSON, `"upstream_type":"pages"`) {
|
||||||
|
t.Fatalf("expected snapshot to include pages route, got %s", result.Version.SnapshotJSON)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Version.SnapshotJSON, `"deployment_id":`) {
|
||||||
|
t.Fatalf("expected snapshot to include pages deployment, got %s", result.Version.SnapshotJSON)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Version.RenderedConfig, "root \"__OPENFLARE_PAGES_DIR__/deployments/") {
|
||||||
|
t.Fatalf("expected rendered config to use pages dir placeholder, got:\n%s", result.Version.RenderedConfig)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Version.SnapshotJSON, `"spa_fallback_path":"/app.html"`) {
|
||||||
|
t.Fatalf("expected snapshot to include custom SPA fallback path, got %s", result.Version.SnapshotJSON)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.Version.RenderedConfig, "try_files $uri $uri/ /app.html;") {
|
||||||
|
t.Fatalf("expected SPA fallback try_files, got:\n%s", result.Version.RenderedConfig)
|
||||||
|
}
|
||||||
|
if strings.Contains(result.Version.RenderedConfig, "proxy_pass") {
|
||||||
|
t.Fatalf("Pages route must not render proxy_pass, got:\n%s", result.Version.RenderedConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPagesProjectRejectsUnsafeFallbackPath(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
_, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "Unsafe Fallback",
|
||||||
|
Slug: "unsafe-fallback",
|
||||||
|
Enabled: true,
|
||||||
|
SPAFallbackEnabled: true,
|
||||||
|
SPAFallbackPath: "/index.html; proxy_pass http://evil",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "回退路径") {
|
||||||
|
t.Fatalf("expected unsafe SPA fallback path rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPagesDeploymentRejectsZipSlip(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "Unsafe Site",
|
||||||
|
Slug: "unsafe-site",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = UploadPagesDeployment(project.ID, multipartFileHeader(t, "bad.zip", testPagesZip(t, map[string]string{
|
||||||
|
"../escape.html": "bad",
|
||||||
|
"index.html": "ok",
|
||||||
|
})), "index.html", "root")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "逃逸目录") {
|
||||||
|
t.Fatalf("expected zip-slip rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPagesRouteRequiresActiveDeployment(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "Draft Site",
|
||||||
|
Slug: "draft-site",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = CreateProxyRoute(ProxyRouteInput{
|
||||||
|
Domain: "draft.example.com",
|
||||||
|
Enabled: true,
|
||||||
|
UpstreamType: "pages",
|
||||||
|
PagesProjectID: &project.ID,
|
||||||
|
}); err == nil || !strings.Contains(err.Error(), "没有激活部署") {
|
||||||
|
t.Fatalf("expected active deployment validation, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPagesDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{Name: "Published Site", Slug: "published-site", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||||
|
}
|
||||||
|
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
|
||||||
|
"index.html": "ok",
|
||||||
|
})), "index.html", "root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPagesDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = ActivatePagesDeployment(project.ID, deployment.ID); err != nil {
|
||||||
|
t.Fatalf("ActivatePagesDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err = GetPagesDeploymentPackagePath(deployment.ID); err == nil || !strings.Contains(err.Error(), "激活配置") {
|
||||||
|
t.Fatalf("expected package download to require active config, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err = CreateProxyRoute(ProxyRouteInput{
|
||||||
|
Domain: "published.example.com",
|
||||||
|
Enabled: true,
|
||||||
|
UpstreamType: "pages",
|
||||||
|
PagesProjectID: &project.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = PublishConfigVersion("root", false); err != nil {
|
||||||
|
t.Fatalf("PublishConfigVersion failed: %v", err)
|
||||||
|
}
|
||||||
|
filePath, fileName, err := GetPagesDeploymentPackagePath(deployment.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetPagesDeploymentPackagePath failed after publish: %v", err)
|
||||||
|
}
|
||||||
|
if filePath == "" || fileName == "" {
|
||||||
|
t.Fatalf("expected package path and file name, got path=%q name=%q", filePath, fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPagesZip(t *testing.T, files map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
writer := zip.NewWriter(&buffer)
|
||||||
|
for name, content := range files {
|
||||||
|
file, err := writer.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatalf("write zip entry failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close zip failed: %v", err)
|
||||||
|
}
|
||||||
|
return buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func multipartFileHeader(t *testing.T, fileName string, content []byte) *multipart.FileHeader {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
part, err := writer.CreateFormFile("package", fileName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = part.Write(content); err != nil {
|
||||||
|
t.Fatalf("write multipart file failed: %v", err)
|
||||||
|
}
|
||||||
|
if err = writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart writer failed: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
if err = req.ParseMultipartForm(int64(len(content)) + 1024); err != nil {
|
||||||
|
t.Fatalf("ParseMultipartForm failed: %v", err)
|
||||||
|
}
|
||||||
|
file, header, err := req.FormFile("package")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FormFile failed: %v", err)
|
||||||
|
}
|
||||||
|
file.Close()
|
||||||
|
return header
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeletePagesDeploymentRejectsActiveDeployment(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{Name: "Active", Slug: "active", Enabled: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||||
|
}
|
||||||
|
deployment, err := UploadPagesDeployment(project.ID, multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{"index.html": "ok"})), "index.html", "root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPagesDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = ActivatePagesDeployment(project.ID, deployment.ID); err != nil {
|
||||||
|
t.Fatalf("ActivatePagesDeployment failed: %v", err)
|
||||||
|
}
|
||||||
|
if err = DeletePagesDeployment(project.ID, deployment.ID); err == nil {
|
||||||
|
t.Fatal("expected active deployment deletion to fail")
|
||||||
|
}
|
||||||
|
var stored model.PagesDeployment
|
||||||
|
if err = model.DB.First(&stored, deployment.ID).Error; err != nil {
|
||||||
|
t.Fatalf("expected active deployment to remain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPagesDeploymentWithTopLevelFolder(t *testing.T) {
|
||||||
|
setupServiceTestDB(t)
|
||||||
|
|
||||||
|
project, err := CreatePagesProject(PagesProjectInput{
|
||||||
|
Name: "Folder Site",
|
||||||
|
Slug: "folder-site",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePagesProject failed: %v", err)
|
||||||
|
}
|
||||||
|
// Upload a zip with all files inside a top-level directory "Speed-Test-source/"
|
||||||
|
uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{
|
||||||
|
"Speed-Test-source/index.html": "<h1>Hello Pages</h1>",
|
||||||
|
"Speed-Test-source/assets/app.js": "console.log('pages')",
|
||||||
|
}))
|
||||||
|
deployment, err := UploadPagesDeployment(project.ID, uploadHeader, "index.html", "root")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UploadPagesDeployment with folder failed: %v", err)
|
||||||
|
}
|
||||||
|
if deployment.FileCount != 2 {
|
||||||
|
t.Fatalf("expected 2 files, got %d", deployment.FileCount)
|
||||||
|
}
|
||||||
|
if deployment.EntryFile != "index.html" {
|
||||||
|
t.Fatalf("expected EntryFile to be index.html, got %q", deployment.EntryFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ type ProxyRouteInput struct {
|
|||||||
TunnelID *uint `json:"tunnel_id"`
|
TunnelID *uint `json:"tunnel_id"`
|
||||||
TunnelTargetAddr string `json:"tunnel_target_addr"`
|
TunnelTargetAddr string `json:"tunnel_target_addr"`
|
||||||
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
|
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
|
||||||
|
PagesProjectID *uint `json:"pages_project_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyRouteView struct {
|
type ProxyRouteView struct {
|
||||||
@@ -106,6 +107,7 @@ type ProxyRouteView struct {
|
|||||||
TunnelID *uint `json:"tunnel_id"`
|
TunnelID *uint `json:"tunnel_id"`
|
||||||
TunnelTargetAddr string `json:"tunnel_target_addr"`
|
TunnelTargetAddr string `json:"tunnel_target_addr"`
|
||||||
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
|
TunnelTargetProtocol string `json:"tunnel_target_protocol"`
|
||||||
|
PagesProjectID *uint `json:"pages_project_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -183,6 +185,13 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
|||||||
// Tunnel type: origin URL is auto-filled during config rendering
|
// Tunnel type: origin URL is auto-filled during config rendering
|
||||||
originURL = "http://127.0.0.1"
|
originURL = "http://127.0.0.1"
|
||||||
upstreams = []string{originURL}
|
upstreams = []string{originURL}
|
||||||
|
} else if upstreamType == "pages" {
|
||||||
|
if err := validatePagesRouteInput(input.PagesProjectID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Keep persisted upstreams HTTP-compatible; Pages rendering uses pages_project_id.
|
||||||
|
originURL = "http://127.0.0.1"
|
||||||
|
upstreams = []string{originURL}
|
||||||
} else {
|
} else {
|
||||||
originURL, originID, err = resolveProxyRoutePrimaryOrigin(input)
|
originURL, originID, err = resolveProxyRoutePrimaryOrigin(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -339,10 +348,17 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
|||||||
route.TunnelNodeID = tunnelNodeID
|
route.TunnelNodeID = tunnelNodeID
|
||||||
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
|
route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr)
|
||||||
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
|
route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol)
|
||||||
|
route.PagesProjectID = nil
|
||||||
|
} else if upstreamType == "pages" {
|
||||||
|
route.TunnelNodeID = nil
|
||||||
|
route.TunnelTargetAddr = ""
|
||||||
|
route.TunnelTargetProtocol = ""
|
||||||
|
route.PagesProjectID = input.PagesProjectID
|
||||||
} else {
|
} else {
|
||||||
route.TunnelNodeID = nil
|
route.TunnelNodeID = nil
|
||||||
route.TunnelTargetAddr = ""
|
route.TunnelTargetAddr = ""
|
||||||
route.TunnelTargetProtocol = ""
|
route.TunnelTargetProtocol = ""
|
||||||
|
route.PagesProjectID = nil
|
||||||
}
|
}
|
||||||
return route, nil
|
return route, nil
|
||||||
}
|
}
|
||||||
@@ -434,6 +450,7 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
|
|||||||
TunnelID: route.TunnelNodeID,
|
TunnelID: route.TunnelNodeID,
|
||||||
TunnelTargetAddr: route.TunnelTargetAddr,
|
TunnelTargetAddr: route.TunnelTargetAddr,
|
||||||
TunnelTargetProtocol: route.TunnelTargetProtocol,
|
TunnelTargetProtocol: route.TunnelTargetProtocol,
|
||||||
|
PagesProjectID: route.PagesProjectID,
|
||||||
CreatedAt: route.CreatedAt,
|
CreatedAt: route.CreatedAt,
|
||||||
UpdatedAt: route.UpdatedAt,
|
UpdatedAt: route.UpdatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -474,6 +491,26 @@ func validateTunnelRouteInput(tunnelNodeID *uint, targetAddr string, targetProto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validatePagesRouteInput(projectID *uint) error {
|
||||||
|
if projectID == nil || *projectID == 0 {
|
||||||
|
return errors.New("pages_project_id is required for Pages upstream")
|
||||||
|
}
|
||||||
|
project, err := model.GetPagesProjectByID(*projectID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("Pages 项目不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !project.Enabled {
|
||||||
|
return errors.New("Pages 项目未启用")
|
||||||
|
}
|
||||||
|
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
|
||||||
|
return errors.New("Pages 项目没有激活部署")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string {
|
func normalizeProxyRouteSiteNameInput(route *model.ProxyRoute, raw string, primaryDomain string) string {
|
||||||
siteName := strings.TrimSpace(raw)
|
siteName := strings.TrimSpace(raw)
|
||||||
if siteName != "" {
|
if siteName != "" {
|
||||||
@@ -1291,6 +1328,8 @@ func normalizeUpstreamType(raw string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
case "tunnel":
|
case "tunnel":
|
||||||
return "tunnel"
|
return "tunnel"
|
||||||
|
case "pages":
|
||||||
|
return "pages"
|
||||||
default:
|
default:
|
||||||
return "direct"
|
return "direct"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -84,6 +85,66 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er
|
|||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = domains[0]
|
displayName = domains[0]
|
||||||
}
|
}
|
||||||
|
cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules}
|
||||||
|
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
|
||||||
|
powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF)
|
||||||
|
if route.PoWEnabled {
|
||||||
|
powEnabled = true
|
||||||
|
}
|
||||||
|
if normalizeRouteUpstreamType(route.UpstreamType) == "pages" {
|
||||||
|
if route.PagesDeployment == nil {
|
||||||
|
return "", fmt.Errorf("route %s pages deployment is missing", route.Domain)
|
||||||
|
}
|
||||||
|
if !route.EnableHTTPS {
|
||||||
|
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certIDs := normalizeCertIDs(route.CertID, route.CertIDs)
|
||||||
|
domainCertIDs := normalizeDomainCertIDs(domains, certIDs, route.DomainCertIDs)
|
||||||
|
if len(certIDs) == 0 {
|
||||||
|
return "", fmt.Errorf("路由 %s 未配置证书", route.Domain)
|
||||||
|
}
|
||||||
|
httpOnlyDomains := make([]string, 0, len(domains))
|
||||||
|
domainsByCertID := make(map[uint][]string, len(certIDs))
|
||||||
|
for index, domain := range domains {
|
||||||
|
if index >= len(domainCertIDs) || domainCertIDs[index] == 0 {
|
||||||
|
httpOnlyDomains = append(httpOnlyDomains, domain)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domainsByCertID[domainCertIDs[index]] = append(domainsByCertID[domainCertIDs[index]], domain)
|
||||||
|
}
|
||||||
|
for _, certID := range certIDs {
|
||||||
|
assignedDomains := domainsByCertID[certID]
|
||||||
|
if len(assignedDomains) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certPEM, ok := certificates[certID]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID)
|
||||||
|
}
|
||||||
|
if err := validateCertificateCoverage(certPEM, assignedDomains); err != nil {
|
||||||
|
return "", fmt.Errorf("site %s certificate validation failed: %w", displayName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if route.RedirectHTTP {
|
||||||
|
if len(httpOnlyDomains) > 0 {
|
||||||
|
builder.WriteString(renderHTTPPagesServer(renderServerNames(httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
|
||||||
|
}
|
||||||
|
for _, certID := range certIDs {
|
||||||
|
if assignedDomains := domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||||
|
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
|
||||||
|
}
|
||||||
|
for _, certID := range certIDs {
|
||||||
|
if assignedDomains := domainsByCertID[certID]; len(assignedDomains) > 0 {
|
||||||
|
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
upstreams := route.Upstreams
|
upstreams := route.Upstreams
|
||||||
if len(upstreams) == 0 && strings.TrimSpace(route.OriginURL) != "" {
|
if len(upstreams) == 0 && strings.TrimSpace(route.OriginURL) != "" {
|
||||||
upstreams = []string{route.OriginURL}
|
upstreams = []string{route.OriginURL}
|
||||||
@@ -92,12 +153,6 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er
|
|||||||
if upstreamConfig.UsesNamedUpstream {
|
if upstreamConfig.UsesNamedUpstream {
|
||||||
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
|
||||||
}
|
}
|
||||||
cacheConfig := routeCacheConfig{Enabled: route.CacheEnabled, Policy: route.CachePolicy, Rules: route.CacheRules}
|
|
||||||
limitConfig := routeLimitConfig{LimitConnPerServer: route.LimitConnPerServer, LimitConnPerIP: route.LimitConnPerIP, LimitRate: route.LimitRate}
|
|
||||||
powEnabled, _ := getPoWConfigForRoute(route.ID, doc.WAF)
|
|
||||||
if route.PoWEnabled {
|
|
||||||
powEnabled = true
|
|
||||||
}
|
|
||||||
if !route.EnableHTTPS {
|
if !route.EnableHTTPS {
|
||||||
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig))
|
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig))
|
||||||
continue
|
continue
|
||||||
@@ -372,6 +427,10 @@ func renderHTTPProxyServer(serverNames string, siteName string, originURL string
|
|||||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
|
||||||
|
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
|
}
|
||||||
|
|
||||||
func renderHTTPRedirectServer(serverNames string) string {
|
func renderHTTPRedirectServer(serverNames string) string {
|
||||||
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames)
|
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames)
|
||||||
}
|
}
|
||||||
@@ -388,6 +447,66 @@ func renderHTTPSServer(serverNames string, siteName string, originURL string, or
|
|||||||
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
|
||||||
|
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
|
||||||
|
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
|
||||||
|
var h3Listen string
|
||||||
|
var h3Header string
|
||||||
|
if cfg.HTTP3Enabled {
|
||||||
|
h3Listen = " listen 443 quic;\n"
|
||||||
|
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderPagesLocationBlock(deployment *PagesDeployment, limitConfig routeLimitConfig) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString(renderRouteLimitBlock(limitConfig))
|
||||||
|
if deployment != nil && deployment.SPAFallbackEnabled {
|
||||||
|
builder.WriteString(fmt.Sprintf(" try_files $uri $uri/ %s;\n", pagesFallbackPath(deployment)))
|
||||||
|
} else {
|
||||||
|
builder.WriteString(" try_files $uri $uri/ =404;\n")
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesDeploymentRoot(deployment *PagesDeployment) string {
|
||||||
|
if deployment == nil || strings.TrimSpace(deployment.LocalRoot) == "" {
|
||||||
|
return PagesDirPlaceholder
|
||||||
|
}
|
||||||
|
return filepathToNginxPath(deployment.LocalRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesEntryFile(deployment *PagesDeployment) string {
|
||||||
|
if deployment == nil || strings.TrimSpace(deployment.EntryFile) == "" {
|
||||||
|
return "index.html"
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(filepathToNginxPath(deployment.EntryFile), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pagesFallbackPath(deployment *PagesDeployment) string {
|
||||||
|
if deployment == nil || strings.TrimSpace(deployment.SPAFallbackPath) == "" {
|
||||||
|
return "/index.html"
|
||||||
|
}
|
||||||
|
value := filepathToNginxPath(strings.TrimSpace(deployment.SPAFallbackPath))
|
||||||
|
if !strings.HasPrefix(value, "/") {
|
||||||
|
value = "/" + value
|
||||||
|
}
|
||||||
|
if value == "/" || strings.HasSuffix(value, "/") || strings.Contains(value, "\\") || strings.ContainsAny(value, "\"';") || strings.ContainsAny(value, " \t\r\n") {
|
||||||
|
return "/index.html"
|
||||||
|
}
|
||||||
|
for _, segment := range strings.Split(value, "/") {
|
||||||
|
if segment == "." || segment == ".." {
|
||||||
|
return "/index.html"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleaned := path.Clean(value)
|
||||||
|
if cleaned == "/" || strings.HasSuffix(cleaned, "/") {
|
||||||
|
return "/index.html"
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string {
|
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
if strings.TrimSpace(originHost) != "" {
|
if strings.TrimSpace(originHost) != "" {
|
||||||
@@ -543,6 +662,15 @@ func buildRouteUpstreamConfig(route Route, upstreams []string) routeUpstreamConf
|
|||||||
return routeUpstreamConfig{Name: buildRouteUpstreamName(route), Scheme: scheme, Servers: servers, UsesNamedUpstream: true}
|
return routeUpstreamConfig{Name: buildRouteUpstreamName(route), Scheme: scheme, Servers: servers, UsesNamedUpstream: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeRouteUpstreamType(raw string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "pages":
|
||||||
|
return "pages"
|
||||||
|
default:
|
||||||
|
return "direct"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string {
|
func renderNamedUpstreamBlock(upstreamConfig routeUpstreamConfig) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name))
|
builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name))
|
||||||
@@ -787,6 +915,10 @@ func quoteNginxStringLiteral(value string) string {
|
|||||||
return fmt.Sprintf(`"%s"`, escaped)
|
return fmt.Sprintf(`"%s"`, escaped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filepathToNginxPath(value string) string {
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(value), `\`, `/`)
|
||||||
|
}
|
||||||
|
|
||||||
func escapeNginxString(value string) string {
|
func escapeNginxString(value string) string {
|
||||||
escaped := strings.ReplaceAll(value, `\`, `\\`)
|
escaped := strings.ReplaceAll(value, `\`, `\\`)
|
||||||
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const (
|
|||||||
ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
|
ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
|
||||||
ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
|
||||||
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
|
||||||
|
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
|
||||||
|
|
||||||
SourceConfigFileName = "openresty_config.json"
|
SourceConfigFileName = "openresty_config.json"
|
||||||
)
|
)
|
||||||
@@ -90,32 +91,46 @@ type PoWConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Route struct {
|
type Route struct {
|
||||||
ID uint `json:"id,omitempty"`
|
ID uint `json:"id,omitempty"`
|
||||||
SiteName string `json:"site_name,omitempty"`
|
SiteName string `json:"site_name,omitempty"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
OriginURL string `json:"origin_url"`
|
OriginURL string `json:"origin_url"`
|
||||||
OriginHost string `json:"origin_host,omitempty"`
|
OriginHost string `json:"origin_host,omitempty"`
|
||||||
Upstreams []string `json:"upstreams,omitempty"`
|
Upstreams []string `json:"upstreams,omitempty"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
EnableHTTPS bool `json:"enable_https"`
|
EnableHTTPS bool `json:"enable_https"`
|
||||||
CertID *uint `json:"cert_id,omitempty"`
|
CertID *uint `json:"cert_id,omitempty"`
|
||||||
CertIDs []uint `json:"cert_ids,omitempty"`
|
CertIDs []uint `json:"cert_ids,omitempty"`
|
||||||
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
|
DomainCertIDs []uint `json:"domain_cert_ids,omitempty"`
|
||||||
RedirectHTTP bool `json:"redirect_http"`
|
RedirectHTTP bool `json:"redirect_http"`
|
||||||
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
|
LimitConnPerServer int `json:"limit_conn_per_server,omitempty"`
|
||||||
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
|
LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"`
|
||||||
LimitRate string `json:"limit_rate,omitempty"`
|
LimitRate string `json:"limit_rate,omitempty"`
|
||||||
CacheEnabled bool `json:"cache_enabled"`
|
CacheEnabled bool `json:"cache_enabled"`
|
||||||
CachePolicy string `json:"cache_policy,omitempty"`
|
CachePolicy string `json:"cache_policy,omitempty"`
|
||||||
CacheRules []string `json:"cache_rules,omitempty"`
|
CacheRules []string `json:"cache_rules,omitempty"`
|
||||||
CustomHeaders []CustomHeader `json:"custom_headers,omitempty"`
|
CustomHeaders []CustomHeader `json:"custom_headers,omitempty"`
|
||||||
PoWEnabled bool `json:"pow_enabled,omitempty"`
|
PoWEnabled bool `json:"pow_enabled,omitempty"`
|
||||||
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
|
PoWConfig *PoWConfig `json:"pow_config,omitempty"`
|
||||||
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
|
BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"`
|
||||||
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
|
BasicAuthUsername string `json:"basic_auth_username,omitempty"`
|
||||||
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
|
BasicAuthPassword string `json:"basic_auth_password,omitempty"`
|
||||||
Remark string `json:"remark,omitempty"`
|
Remark string `json:"remark,omitempty"`
|
||||||
|
UpstreamType string `json:"upstream_type,omitempty"`
|
||||||
|
PagesDeployment *PagesDeployment `json:"pages_deployment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesDeployment struct {
|
||||||
|
ProjectID uint `json:"project_id"`
|
||||||
|
ProjectSlug string `json:"project_slug"`
|
||||||
|
DeploymentID uint `json:"deployment_id"`
|
||||||
|
DeploymentNumber int `json:"deployment_number"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
EntryFile string `json:"entry_file"`
|
||||||
|
SPAFallbackEnabled bool `json:"spa_fallback_enabled"`
|
||||||
|
SPAFallbackPath string `json:"spa_fallback_path"`
|
||||||
|
LocalRoot string `json:"local_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WAFRuleGroup struct {
|
type WAFRuleGroup struct {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { PagesProjectDetailPage } from '@/features/pages/components/pages-page';
|
||||||
|
|
||||||
|
export default function PagesProjectDetailRoute() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
return <PagesProjectDetailPage projectId={searchParams.get('id') ?? ''} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PagesPage } from '@/features/pages/components/pages-page';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PagesPage />;
|
||||||
|
}
|
||||||
@@ -80,6 +80,15 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
|||||||
<path d="m14 15 3 2-3 2" />
|
<path d="m14 15 3 2-3 2" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'pages':
|
||||||
|
return (
|
||||||
|
<svg {...commonProps}>
|
||||||
|
<path d="M5 5.5h14v13H5z" />
|
||||||
|
<path d="M8 9h8" />
|
||||||
|
<path d="M8 12h5" />
|
||||||
|
<path d="M8 15h7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'waf':
|
case 'waf':
|
||||||
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
|
return <ShieldCheck className="h-[18px] w-[18px]" strokeWidth={1.8} />;
|
||||||
case 'release':
|
case 'release':
|
||||||
@@ -208,7 +217,6 @@ function SidebarContent({
|
|||||||
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
OpenFlare
|
OpenFlare
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--foreground-secondary)]">控制面</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PagesDeployment,
|
||||||
|
PagesProject,
|
||||||
|
PagesProjectPayload,
|
||||||
|
} from '@/features/pages/types';
|
||||||
|
|
||||||
|
export function getPagesProjects() {
|
||||||
|
return apiRequest<PagesProject[]>('/pages/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPagesProject(id: number) {
|
||||||
|
return apiRequest<PagesProject>(`/pages/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPagesProject(payload: PagesProjectPayload) {
|
||||||
|
return apiRequest<PagesProject>('/pages/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePagesProject(id: number, payload: PagesProjectPayload) {
|
||||||
|
return apiRequest<PagesProject>(`/pages/${id}/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePagesProject(id: number) {
|
||||||
|
return apiRequest<void>(`/pages/${id}/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPagesDeployments(projectId: number) {
|
||||||
|
return apiRequest<PagesDeployment[]>(`/pages/${projectId}/deployments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadPagesDeployment(
|
||||||
|
projectId: number,
|
||||||
|
file: File,
|
||||||
|
entryFile = 'index.html',
|
||||||
|
) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('package', file);
|
||||||
|
formData.append('entry_file', entryFile);
|
||||||
|
return apiRequest<PagesDeployment>(`/pages/${projectId}/deployments/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activatePagesDeployment(
|
||||||
|
projectId: number,
|
||||||
|
deploymentId: number,
|
||||||
|
) {
|
||||||
|
return apiRequest<PagesProject>(
|
||||||
|
`/pages/${projectId}/deployments/${deploymentId}/activate`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePagesDeployment(projectId: number, deploymentId: number) {
|
||||||
|
return apiRequest<void>(
|
||||||
|
`/pages/${projectId}/deployments/${deploymentId}/delete`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { AppModal } from '@/components/ui/app-modal';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
import {
|
||||||
|
activatePagesDeployment,
|
||||||
|
createPagesProject,
|
||||||
|
deletePagesDeployment,
|
||||||
|
deletePagesProject,
|
||||||
|
getPagesProject,
|
||||||
|
getPagesDeployments,
|
||||||
|
getPagesProjects,
|
||||||
|
uploadPagesDeployment,
|
||||||
|
} from '@/features/pages/api/pages';
|
||||||
|
import type { PagesProject } from '@/features/pages/types';
|
||||||
|
import {
|
||||||
|
DangerButton,
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
|
SecondaryButton,
|
||||||
|
ToggleField,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
|
||||||
|
const projectsQueryKey = ['pages-projects'];
|
||||||
|
|
||||||
|
function projectQueryKey(projectId: string | number) {
|
||||||
|
return ['pages-project', String(projectId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deploymentsQueryKey(projectId: string | number) {
|
||||||
|
return ['pages-deployments', Number(projectId)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (value < 1024) {
|
||||||
|
return `${value} B`;
|
||||||
|
}
|
||||||
|
if (value < 1024 * 1024) {
|
||||||
|
return `${(value / 1024).toFixed(1)} KiB`;
|
||||||
|
}
|
||||||
|
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return '未激活';
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PagesPage() {
|
||||||
|
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: projectsQueryKey,
|
||||||
|
queryFn: getPagesProjects,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-secondary)]">
|
||||||
|
OpenFlare Pages
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-semibold text-[var(--foreground-primary)]">
|
||||||
|
边缘静态站点托管
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-3xl text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
|
创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages
|
||||||
|
项目作为上游。发布后 Agent
|
||||||
|
会拉取部署包并在边缘节点本地服务静态文件。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
className="w-full lg:w-auto"
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
新建 Pages 项目
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projectsQuery.isLoading ? (
|
||||||
|
<AppCard>正在加载 Pages 项目...</AppCard>
|
||||||
|
) : projectsQuery.error ? (
|
||||||
|
<AppCard>
|
||||||
|
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||||
|
{projectsQuery.error.message}
|
||||||
|
</p>
|
||||||
|
</AppCard>
|
||||||
|
) : (projectsQuery.data ?? []).length === 0 ? (
|
||||||
|
<AppCard
|
||||||
|
title="还没有 Pages 项目"
|
||||||
|
description="先创建一个项目,再上传静态资源包。"
|
||||||
|
action={
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
新建 Pages 项目
|
||||||
|
</SecondaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
</AppCard>
|
||||||
|
) : (
|
||||||
|
(projectsQuery.data ?? []).map((project) => (
|
||||||
|
<PagesProjectListItem key={project.id} project={project} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PagesProjectCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setCreateModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [entryFile, setEntryFile] = useState('index.html');
|
||||||
|
|
||||||
|
const parsedProjectId = Number(projectId);
|
||||||
|
const projectQuery = useQuery({
|
||||||
|
queryKey: projectQueryKey(projectId),
|
||||||
|
queryFn: () => getPagesProject(parsedProjectId),
|
||||||
|
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||||
|
});
|
||||||
|
const deploymentsQuery = useQuery({
|
||||||
|
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||||
|
queryFn: () => getPagesDeployments(parsedProjectId),
|
||||||
|
enabled: projectId !== '' && Number.isFinite(parsedProjectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('请选择 zip 文件');
|
||||||
|
}
|
||||||
|
return uploadPagesDeployment(parsedProjectId, file, entryFile);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setFile(null);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const activateMutation = useMutation({
|
||||||
|
mutationFn: (deploymentId: number) =>
|
||||||
|
activatePagesDeployment(parsedProjectId, deploymentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteDeploymentMutation = useMutation({
|
||||||
|
mutationFn: (deploymentId: number) =>
|
||||||
|
deletePagesDeployment(parsedProjectId, deploymentId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteProjectMutation = useMutation({
|
||||||
|
mutationFn: () => deletePagesProject(parsedProjectId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||||
|
router.push('/pages');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (projectId === '' || !Number.isFinite(parsedProjectId)) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="Pages 项目不存在"
|
||||||
|
description="缺少有效的 Pages 项目 ID,请从项目列表重新进入。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectQuery.isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="Pages 项目加载失败"
|
||||||
|
description={projectQuery.error.message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = projectQuery.data;
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="Pages 项目不存在"
|
||||||
|
description="该项目可能已被删除,或当前 ID 无法匹配到项目记录。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteProject = () => {
|
||||||
|
if (!window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteProjectMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={project.name}
|
||||||
|
description={`${project.slug} · Pages 静态站点项目详情`}
|
||||||
|
action={
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href="/pages"
|
||||||
|
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||||
|
>
|
||||||
|
返回列表
|
||||||
|
</Link>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
disabled={deleteProjectMutation.isPending}
|
||||||
|
onClick={handleDeleteProject}
|
||||||
|
>
|
||||||
|
删除项目
|
||||||
|
</DangerButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||||
|
<AppCard
|
||||||
|
title="上传部署包"
|
||||||
|
description="上传已构建的 zip 静态资源包,默认入口 index.html。"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ResourceField
|
||||||
|
label="部署包"
|
||||||
|
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="入口文件">
|
||||||
|
<ResourceInput
|
||||||
|
value={entryFile}
|
||||||
|
onChange={(event) => setEntryFile(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={!file || uploadMutation.isPending}
|
||||||
|
onClick={() => uploadMutation.mutate()}
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending ? '上传中...' : '上传部署'}
|
||||||
|
</PrimaryButton>
|
||||||
|
{uploadMutation.error ? (
|
||||||
|
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||||
|
{uploadMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title="部署历史"
|
||||||
|
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
|
||||||
|
>
|
||||||
|
{deploymentsQuery.isLoading ? (
|
||||||
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
|
加载中...
|
||||||
|
</p>
|
||||||
|
) : deploymentsQuery.isError ? (
|
||||||
|
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||||
|
{deploymentsQuery.error.message}
|
||||||
|
</p>
|
||||||
|
) : (deploymentsQuery.data ?? []).length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无部署"
|
||||||
|
description="上传 zip 部署包后,可以在这里激活某个部署版本。"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||||
|
{(deploymentsQuery.data ?? []).map((deployment) => (
|
||||||
|
<div
|
||||||
|
key={deployment.id}
|
||||||
|
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
#{deployment.deployment_number}{' '}
|
||||||
|
{deployment.status === 'active' ? '· 已激活' : ''}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-[var(--foreground-secondary)]">
|
||||||
|
{deployment.checksum.slice(0, 16)} ·{' '}
|
||||||
|
{deployment.file_count} files ·{' '}
|
||||||
|
{formatBytes(deployment.total_size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
deployment.status === 'active' ||
|
||||||
|
activateMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`确认激活部署 #${deployment.deployment_number} 吗?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
activateMutation.mutate(deployment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
激活
|
||||||
|
</SecondaryButton>
|
||||||
|
<DangerButton
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
deployment.status === 'active' ||
|
||||||
|
deleteDeploymentMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`确认删除部署 #${deployment.deployment_number} 吗?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteDeploymentMutation.mutate(deployment.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagesProjectCreateModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false);
|
||||||
|
const [spaFallbackPath, setSpaFallbackPath] = useState('/index.html');
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName('');
|
||||||
|
setSlug('');
|
||||||
|
setDescription('');
|
||||||
|
setSpaFallbackEnabled(false);
|
||||||
|
setSpaFallbackPath('/index.html');
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createPagesProject({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
enabled: true,
|
||||||
|
spa_fallback_enabled: spaFallbackEnabled,
|
||||||
|
spa_fallback_path: spaFallbackPath,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
createMutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={closeModal}
|
||||||
|
title="新建 Pages 项目"
|
||||||
|
description="配置静态站点项目的基础信息。创建后再上传已构建的 zip 部署包。"
|
||||||
|
footer={
|
||||||
|
<div className="flex flex-wrap justify-end gap-3">
|
||||||
|
<SecondaryButton type="button" onClick={closeModal}>
|
||||||
|
取消
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="submit"
|
||||||
|
form="pages-project-create-form"
|
||||||
|
disabled={createMutation.isPending || name.trim() === ''}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? '创建中...' : '创建项目'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
id="pages-project-create-form"
|
||||||
|
className="grid gap-4 md:grid-cols-2"
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
>
|
||||||
|
<ResourceField label="项目名称">
|
||||||
|
<ResourceInput
|
||||||
|
value={name}
|
||||||
|
placeholder="Marketing Site"
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="项目标识" hint="留空时会按名称自动生成。">
|
||||||
|
<ResourceInput
|
||||||
|
value={slug}
|
||||||
|
placeholder="marketing-site"
|
||||||
|
onChange={(event) => setSlug(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ResourceField label="描述" className="md:col-span-2">
|
||||||
|
<ResourceInput
|
||||||
|
value={description}
|
||||||
|
placeholder="这个项目托管的静态站点用途"
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
<ToggleField
|
||||||
|
label="启用 SPA fallback"
|
||||||
|
description="开启后未命中的路径会回退到指定文件,适合 React/Vue history 路由。"
|
||||||
|
checked={spaFallbackEnabled}
|
||||||
|
onChange={setSpaFallbackEnabled}
|
||||||
|
/>
|
||||||
|
<ResourceField
|
||||||
|
label="SPA 回退路径"
|
||||||
|
hint="以 / 开头,例如 /index.html 或 /app.html。关闭 fallback 时不会生效。"
|
||||||
|
>
|
||||||
|
<ResourceInput
|
||||||
|
value={spaFallbackPath}
|
||||||
|
placeholder="/index.html"
|
||||||
|
disabled={!spaFallbackEnabled}
|
||||||
|
onChange={(event) => setSpaFallbackPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
{createMutation.error ? (
|
||||||
|
<p className="text-sm text-[var(--status-danger-foreground)] md:col-span-2">
|
||||||
|
{createMutation.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagesProjectListItem({ project }: { project: PagesProject }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/pages/detail?id=${project.id}`}
|
||||||
|
className="group block rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-panel)] p-5 shadow-[var(--shadow-card)] transition hover:-translate-y-0.5 hover:border-[var(--border-strong)] hover:shadow-[var(--shadow-soft)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{project.name}
|
||||||
|
</h2>
|
||||||
|
<StatusBadge
|
||||||
|
label={project.enabled ? '已启用' : '已停用'}
|
||||||
|
variant={project.enabled ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
<StatusBadge
|
||||||
|
label={project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
|
||||||
|
variant={project.spa_fallback_enabled ? 'info' : 'warning'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||||
|
{project.slug}
|
||||||
|
</p>
|
||||||
|
{project.description ? (
|
||||||
|
<p className="line-clamp-2 text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{project.spa_fallback_enabled ? (
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
回退路径:{project.spa_fallback_path || '/index.html'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid shrink-0 grid-cols-2 gap-3 text-sm md:min-w-80">
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">部署数</p>
|
||||||
|
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{project.deployment_count}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
当前激活
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{project.active_deployment
|
||||||
|
? `#${project.active_deployment.deployment_number}`
|
||||||
|
: '暂无'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-default)] pt-4">
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">
|
||||||
|
激活时间:{formatDate(project.active_deployment?.activated_at)}
|
||||||
|
</p>
|
||||||
|
<span className="text-sm font-medium text-[var(--brand-primary)] transition group-hover:translate-x-1">
|
||||||
|
查看详情 →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewItem({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-3">
|
||||||
|
<p className="text-xs text-[var(--foreground-secondary)]">{label}</p>
|
||||||
|
<p className="mt-2 truncate text-sm font-semibold text-[var(--foreground-primary)]">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-[var(--foreground-secondary)]">{hint}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface PagesDeployment {
|
||||||
|
id: number;
|
||||||
|
project_id: number;
|
||||||
|
deployment_number: number;
|
||||||
|
checksum: string;
|
||||||
|
status: 'uploaded' | 'active';
|
||||||
|
file_count: number;
|
||||||
|
total_size: number;
|
||||||
|
entry_file: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
activated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagesProject {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
spa_fallback_enabled: boolean;
|
||||||
|
spa_fallback_path: string;
|
||||||
|
active_deployment_id?: number | null;
|
||||||
|
active_deployment?: PagesDeployment | null;
|
||||||
|
deployment_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagesProjectPayload {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
spa_fallback_enabled: boolean;
|
||||||
|
spa_fallback_path: string;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { LoadingState } from '@/components/feedback/loading-state';
|
|||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||||||
|
import { getPagesProjects } from '@/features/pages/api/pages';
|
||||||
import {
|
import {
|
||||||
getProxyRoute,
|
getProxyRoute,
|
||||||
updateProxyRoute,
|
updateProxyRoute,
|
||||||
@@ -157,12 +158,13 @@ const rateLimitSchema = z
|
|||||||
|
|
||||||
const reverseProxySchema = z
|
const reverseProxySchema = z
|
||||||
.object({
|
.object({
|
||||||
upstream_type: z.enum(['direct', 'tunnel']),
|
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||||
origin_urls_text: z.string().trim(),
|
origin_urls_text: z.string().trim(),
|
||||||
origin_host: z.string(),
|
origin_host: z.string(),
|
||||||
tunnel_id: z.string().optional(),
|
tunnel_id: z.string().optional(),
|
||||||
tunnel_target_addr: z.string().trim().optional(),
|
tunnel_target_addr: z.string().trim().optional(),
|
||||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||||
|
pages_project_id: z.string().optional(),
|
||||||
custom_headers_text: z.string(),
|
custom_headers_text: z.string(),
|
||||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||||
})
|
})
|
||||||
@@ -184,7 +186,7 @@ const reverseProxySchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (value.upstream_type === 'tunnel') {
|
||||||
if (!value.tunnel_id) {
|
if (!value.tunnel_id) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -199,6 +201,12 @@ const reverseProxySchema = z
|
|||||||
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (!value.pages_project_id) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['pages_project_id'],
|
||||||
|
message: '请选择 Pages 项目',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const originHostError = validateOriginHost(value.origin_host);
|
const originHostError = validateOriginHost(value.origin_host);
|
||||||
@@ -552,8 +560,17 @@ function ReverseProxySection({
|
|||||||
queryKey: ['nodes'],
|
queryKey: ['nodes'],
|
||||||
queryFn: getNodes,
|
queryFn: getNodes,
|
||||||
});
|
});
|
||||||
|
const pagesProjectsQuery = useQuery({
|
||||||
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
|
queryKey: ['pages-projects'],
|
||||||
|
queryFn: getPagesProjects,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tunnelClients = (tunnelsQuery.data ?? []).filter(
|
||||||
|
(node) => node.node_type === 'tunnel_client',
|
||||||
|
);
|
||||||
|
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
|
||||||
|
(project) => project.enabled && project.active_deployment_id,
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm<ReverseProxyValues>({
|
const form = useForm<ReverseProxyValues>({
|
||||||
resolver: zodResolver(reverseProxySchema),
|
resolver: zodResolver(reverseProxySchema),
|
||||||
@@ -564,6 +581,7 @@ function ReverseProxySection({
|
|||||||
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
|
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
|
||||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||||
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||||
|
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||||
remark: route.remark || '',
|
remark: route.remark || '',
|
||||||
},
|
},
|
||||||
@@ -577,6 +595,7 @@ function ReverseProxySection({
|
|||||||
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
|
tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '',
|
||||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||||
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
tunnel_target_protocol: (route.tunnel_target_protocol as 'http' | 'https') || 'http',
|
||||||
|
pages_project_id: route.pages_project_id ? String(route.pages_project_id) : '',
|
||||||
custom_headers_text: customHeadersToText(route.custom_header_list),
|
custom_headers_text: customHeadersToText(route.custom_header_list),
|
||||||
remark: route.remark || '',
|
remark: route.remark || '',
|
||||||
});
|
});
|
||||||
@@ -609,12 +628,17 @@ function ReverseProxySection({
|
|||||||
originPort = primaryOrigin.port;
|
originPort = primaryOrigin.port;
|
||||||
originUri = primaryOrigin.uri;
|
originUri = primaryOrigin.uri;
|
||||||
upstreams = urls.slice(1);
|
upstreams = urls.slice(1);
|
||||||
} else {
|
} else if (values.upstream_type === 'tunnel') {
|
||||||
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
||||||
originScheme = values.tunnel_target_protocol as 'http' | 'https';
|
originScheme = values.tunnel_target_protocol as 'http' | 'https';
|
||||||
originAddress = values.tunnel_target_addr || '';
|
originAddress = values.tunnel_target_addr || '';
|
||||||
|
} else {
|
||||||
|
originUrl = 'http://127.0.0.1';
|
||||||
|
originScheme = 'http';
|
||||||
|
originAddress = '127.0.0.1';
|
||||||
|
originPort = '80';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { headers } = parseCustomHeadersText(
|
const { headers } = parseCustomHeadersText(
|
||||||
values.custom_headers_text,
|
values.custom_headers_text,
|
||||||
);
|
);
|
||||||
@@ -632,9 +656,22 @@ function ReverseProxySection({
|
|||||||
custom_headers: headers,
|
custom_headers: headers,
|
||||||
remark: values.remark.trim(),
|
remark: values.remark.trim(),
|
||||||
upstream_type: values.upstream_type,
|
upstream_type: values.upstream_type,
|
||||||
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
|
tunnel_node_id:
|
||||||
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
|
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||||
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
|
? Number(values.tunnel_id)
|
||||||
|
: null,
|
||||||
|
tunnel_target_addr:
|
||||||
|
values.upstream_type === 'tunnel'
|
||||||
|
? values.tunnel_target_addr
|
||||||
|
: '',
|
||||||
|
tunnel_target_protocol:
|
||||||
|
values.upstream_type === 'tunnel'
|
||||||
|
? values.tunnel_target_protocol
|
||||||
|
: '',
|
||||||
|
pages_project_id:
|
||||||
|
values.upstream_type === 'pages' && values.pages_project_id
|
||||||
|
? Number(values.pages_project_id)
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
{ message: '反向代理设置已保存。' },
|
{ message: '反向代理设置已保存。' },
|
||||||
);
|
);
|
||||||
@@ -661,6 +698,15 @@ function ReverseProxySection({
|
|||||||
/>
|
/>
|
||||||
内网穿透 (Tunnel)
|
内网穿透 (Tunnel)
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="pages"
|
||||||
|
{...form.register('upstream_type')}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
Pages 静态站点
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -679,7 +725,7 @@ function ReverseProxySection({
|
|||||||
{...form.register('origin_urls_text')}
|
{...form.register('origin_urls_text')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
) : (
|
) : form.watch('upstream_type') === 'tunnel' ? (
|
||||||
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="选择内网穿透隧道"
|
label="选择内网穿透隧道"
|
||||||
@@ -724,6 +770,26 @@ function ReverseProxySection({
|
|||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||||
|
<ResourceField
|
||||||
|
label="选择 Pages 项目"
|
||||||
|
hint="仅显示已启用且已有激活部署的 Pages 项目。"
|
||||||
|
error={form.formState.errors.pages_project_id?.message}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
{...form.register('pages_project_id')}
|
||||||
|
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||||
|
>
|
||||||
|
<option value="">请选择...</option>
|
||||||
|
{pagesProjects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name} ({project.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ResourceField
|
<ResourceField
|
||||||
|
|||||||
+87
-14
@@ -8,6 +8,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { Drawer } from '@/components/ui/drawer';
|
import { Drawer } from '@/components/ui/drawer';
|
||||||
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||||||
|
import { getPagesProjects } from '@/features/pages/api/pages';
|
||||||
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
|
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
|
||||||
import {
|
import {
|
||||||
DomainListInput,
|
DomainListInput,
|
||||||
@@ -40,11 +41,12 @@ const createWebsiteSchema = z
|
|||||||
.object({
|
.object({
|
||||||
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
|
||||||
domain_rows: z.array(domainRowSchema).min(1),
|
domain_rows: z.array(domainRowSchema).min(1),
|
||||||
upstream_type: z.enum(['direct', 'tunnel']),
|
upstream_type: z.enum(['direct', 'tunnel', 'pages']),
|
||||||
origin_urls_text: z.string().trim(),
|
origin_urls_text: z.string().trim(),
|
||||||
tunnel_id: z.string().optional(),
|
tunnel_id: z.string().optional(),
|
||||||
tunnel_target_addr: z.string().trim().optional(),
|
tunnel_target_addr: z.string().trim().optional(),
|
||||||
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
tunnel_target_protocol: z.enum(['http', 'https']).optional(),
|
||||||
|
pages_project_id: z.string().optional(),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
redirect_http: z.boolean(),
|
redirect_http: z.boolean(),
|
||||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||||
@@ -79,7 +81,7 @@ const createWebsiteSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (value.upstream_type === 'tunnel') {
|
||||||
if (!value.tunnel_id) {
|
if (!value.tunnel_id) {
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -94,6 +96,12 @@ const createWebsiteSchema = z
|
|||||||
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
message: '请填写内网服务地址 (如 127.0.0.1:8080)',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (!value.pages_project_id) {
|
||||||
|
context.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['pages_project_id'],
|
||||||
|
message: '请选择 Pages 项目',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCertificateCount = new Set(
|
const selectedCertificateCount = new Set(
|
||||||
@@ -120,6 +128,7 @@ const defaultValues: CreateWebsiteFormValues = {
|
|||||||
tunnel_id: '',
|
tunnel_id: '',
|
||||||
tunnel_target_addr: '',
|
tunnel_target_addr: '',
|
||||||
tunnel_target_protocol: 'http',
|
tunnel_target_protocol: 'http',
|
||||||
|
pages_project_id: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
redirect_http: false,
|
redirect_http: false,
|
||||||
remark: '',
|
remark: '',
|
||||||
@@ -177,7 +186,17 @@ export function ProxyRouteCreateDrawer({
|
|||||||
queryFn: getNodes,
|
queryFn: getNodes,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client');
|
const pagesProjectsQuery = useQuery({
|
||||||
|
queryKey: ['pages-projects'],
|
||||||
|
queryFn: getPagesProjects,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const tunnelClients = (tunnelsQuery.data ?? []).filter(
|
||||||
|
(node) => node.node_type === 'tunnel_client',
|
||||||
|
);
|
||||||
|
const pagesProjects = (pagesProjectsQuery.data ?? []).filter(
|
||||||
|
(project) => project.enabled && project.active_deployment_id,
|
||||||
|
);
|
||||||
|
|
||||||
const combinedDomainSuggestions = useMemo(
|
const combinedDomainSuggestions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -199,11 +218,11 @@ export function ProxyRouteCreateDrawer({
|
|||||||
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
const selectedCertIDs = normalizeSelectedCertificateIDs(
|
||||||
values.domain_rows,
|
values.domain_rows,
|
||||||
);
|
);
|
||||||
|
|
||||||
let originUrl = '';
|
let originUrl = '';
|
||||||
const originHost = '';
|
const originHost = '';
|
||||||
let upstreams: string[] = [];
|
let upstreams: string[] = [];
|
||||||
|
|
||||||
if (values.upstream_type === 'direct') {
|
if (values.upstream_type === 'direct') {
|
||||||
const { urls } = parseOriginUrls(values.origin_urls_text);
|
const { urls } = parseOriginUrls(values.origin_urls_text);
|
||||||
const primaryOrigin = parseOriginUrl(urls[0]);
|
const primaryOrigin = parseOriginUrl(urls[0]);
|
||||||
@@ -214,8 +233,10 @@ export function ProxyRouteCreateDrawer({
|
|||||||
primaryOrigin.uri,
|
primaryOrigin.uri,
|
||||||
);
|
);
|
||||||
upstreams = urls.slice(1);
|
upstreams = urls.slice(1);
|
||||||
} else {
|
} else if (values.upstream_type === 'tunnel') {
|
||||||
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`;
|
||||||
|
} else {
|
||||||
|
originUrl = 'http://127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
return createProxyRoute({
|
return createProxyRoute({
|
||||||
@@ -224,10 +245,22 @@ export function ProxyRouteCreateDrawer({
|
|||||||
domains,
|
domains,
|
||||||
origin_id: null,
|
origin_id: null,
|
||||||
origin_url: originUrl,
|
origin_url: originUrl,
|
||||||
origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http',
|
origin_scheme:
|
||||||
origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '',
|
values.upstream_type === 'direct'
|
||||||
origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80',
|
? parseOriginUrl(originUrl).scheme
|
||||||
origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '',
|
: 'http',
|
||||||
|
origin_address:
|
||||||
|
values.upstream_type === 'direct'
|
||||||
|
? parseOriginUrl(originUrl).address
|
||||||
|
: values.tunnel_target_addr || '127.0.0.1',
|
||||||
|
origin_port:
|
||||||
|
values.upstream_type === 'direct'
|
||||||
|
? parseOriginUrl(originUrl).port
|
||||||
|
: '80',
|
||||||
|
origin_uri:
|
||||||
|
values.upstream_type === 'direct'
|
||||||
|
? parseOriginUrl(originUrl).uri
|
||||||
|
: '',
|
||||||
origin_host: originHost,
|
origin_host: originHost,
|
||||||
upstreams,
|
upstreams,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
@@ -249,9 +282,20 @@ export function ProxyRouteCreateDrawer({
|
|||||||
basic_auth_enabled: false,
|
basic_auth_enabled: false,
|
||||||
remark: values.remark.trim(),
|
remark: values.remark.trim(),
|
||||||
upstream_type: values.upstream_type,
|
upstream_type: values.upstream_type,
|
||||||
tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? Number(values.tunnel_id) : null,
|
tunnel_node_id:
|
||||||
tunnel_target_addr: values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
|
values.upstream_type === 'tunnel' && values.tunnel_id
|
||||||
tunnel_target_protocol: values.upstream_type === 'tunnel' ? values.tunnel_target_protocol : '',
|
? Number(values.tunnel_id)
|
||||||
|
: null,
|
||||||
|
tunnel_target_addr:
|
||||||
|
values.upstream_type === 'tunnel' ? values.tunnel_target_addr : '',
|
||||||
|
tunnel_target_protocol:
|
||||||
|
values.upstream_type === 'tunnel'
|
||||||
|
? values.tunnel_target_protocol
|
||||||
|
: '',
|
||||||
|
pages_project_id:
|
||||||
|
values.upstream_type === 'pages' && values.pages_project_id
|
||||||
|
? Number(values.pages_project_id)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: (route) => {
|
onSuccess: (route) => {
|
||||||
@@ -359,6 +403,15 @@ export function ProxyRouteCreateDrawer({
|
|||||||
/>
|
/>
|
||||||
内网穿透 (Tunnel)
|
内网穿透 (Tunnel)
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-[var(--foreground-primary)] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
value="pages"
|
||||||
|
{...form.register('upstream_type')}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
Pages 静态站点
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,7 +429,7 @@ export function ProxyRouteCreateDrawer({
|
|||||||
{...form.register('origin_urls_text')}
|
{...form.register('origin_urls_text')}
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
) : (
|
) : form.watch('upstream_type') === 'tunnel' ? (
|
||||||
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="选择内网穿透隧道"
|
label="选择内网穿透隧道"
|
||||||
@@ -421,6 +474,26 @@ export function ProxyRouteCreateDrawer({
|
|||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded-xl border border-[var(--border-default)] bg-[var(--surface-muted)] space-y-4">
|
||||||
|
<ResourceField
|
||||||
|
label="选择 Pages 项目"
|
||||||
|
hint="仅显示已启用且已有激活部署的 Pages 项目。"
|
||||||
|
error={form.formState.errors.pages_project_id?.message}
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
{...form.register('pages_project_id')}
|
||||||
|
className="block w-full rounded-xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-2.5 text-sm text-[var(--foreground-primary)] placeholder-[var(--foreground-muted)] outline-none transition focus:border-[var(--border-strong)] focus:ring-1 focus:ring-[var(--border-strong)]"
|
||||||
|
>
|
||||||
|
<option value="">请选择...</option>
|
||||||
|
{pagesProjects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name} ({project.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</ResourceField>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ToggleField
|
<ToggleField
|
||||||
|
|||||||
@@ -267,7 +267,10 @@ export function buildPayloadFromRoute(
|
|||||||
route: ProxyRouteItem,
|
route: ProxyRouteItem,
|
||||||
overrides: Partial<ProxyRouteMutationPayload>,
|
overrides: Partial<ProxyRouteMutationPayload>,
|
||||||
): ProxyRouteMutationPayload {
|
): ProxyRouteMutationPayload {
|
||||||
const primaryOrigin = parseOriginUrl(route.origin_url);
|
const primaryOrigin =
|
||||||
|
route.upstream_type === 'pages'
|
||||||
|
? parseOriginUrl('http://127.0.0.1')
|
||||||
|
: parseOriginUrl(route.origin_url);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
site_name: route.site_name,
|
site_name: route.site_name,
|
||||||
@@ -304,11 +307,17 @@ export function buildPayloadFromRoute(
|
|||||||
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
|
tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null,
|
||||||
tunnel_target_addr: route.tunnel_target_addr || '',
|
tunnel_target_addr: route.tunnel_target_addr || '',
|
||||||
tunnel_target_protocol: route.tunnel_target_protocol || '',
|
tunnel_target_protocol: route.tunnel_target_protocol || '',
|
||||||
|
pages_project_id: route.pages_project_id ?? null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUpstreamSummary(route: ProxyRouteItem) {
|
export function getUpstreamSummary(route: ProxyRouteItem) {
|
||||||
|
if (route.upstream_type === 'pages') {
|
||||||
|
return route.pages_project_id
|
||||||
|
? `Pages 项目 #${route.pages_project_id}`
|
||||||
|
: 'Pages 项目未绑定';
|
||||||
|
}
|
||||||
if (route.upstream_type === 'tunnel') {
|
if (route.upstream_type === 'tunnel') {
|
||||||
const protocol = route.tunnel_target_protocol || 'http';
|
const protocol = route.tunnel_target_protocol || 'http';
|
||||||
const target = route.tunnel_target_addr || '未配置目标';
|
const target = route.tunnel_target_addr || '未配置目标';
|
||||||
|
|||||||
@@ -53,11 +53,12 @@ export interface ProxyRouteItem {
|
|||||||
basic_auth_username: string;
|
basic_auth_username: string;
|
||||||
basic_auth_password: string;
|
basic_auth_password: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
upstream_type: 'direct' | 'tunnel';
|
upstream_type: 'direct' | 'tunnel' | 'pages';
|
||||||
tunnel_node_id?: number | null;
|
tunnel_node_id?: number | null;
|
||||||
tunnel_id?: number | null;
|
tunnel_id?: number | null;
|
||||||
tunnel_target_addr?: string;
|
tunnel_target_addr?: string;
|
||||||
tunnel_target_protocol?: string;
|
tunnel_target_protocol?: string;
|
||||||
|
pages_project_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -93,11 +94,12 @@ export interface ProxyRouteMutationPayload {
|
|||||||
basic_auth_username?: string;
|
basic_auth_username?: string;
|
||||||
basic_auth_password?: string;
|
basic_auth_password?: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
upstream_type?: 'direct' | 'tunnel';
|
upstream_type?: 'direct' | 'tunnel' | 'pages';
|
||||||
tunnel_node_id?: number | null;
|
tunnel_node_id?: number | null;
|
||||||
tunnel_id?: number | null;
|
tunnel_id?: number | null;
|
||||||
tunnel_target_addr?: string;
|
tunnel_target_addr?: string;
|
||||||
tunnel_target_protocol?: string;
|
tunnel_target_protocol?: string;
|
||||||
|
pages_project_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TlsCertificateItem {
|
export interface TlsCertificateItem {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const dashboardNavigation: NavigationItem[] = [
|
|||||||
label: '规则',
|
label: '规则',
|
||||||
icon: 'proxy',
|
icon: 'proxy',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/pages',
|
||||||
|
label: 'Pages',
|
||||||
|
icon: 'pages',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/website',
|
href: '/website',
|
||||||
label: '网站',
|
label: '网站',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ function buildNode(overrides: Partial<NodeItem> = {}): NodeItem {
|
|||||||
relay_client_proxy_url: '',
|
relay_client_proxy_url: '',
|
||||||
relay_auth_token: '',
|
relay_auth_token: '',
|
||||||
relay_status: 'healthy',
|
relay_status: 'healthy',
|
||||||
|
relay_web_server_enabled: false,
|
||||||
relay_frps_connections: 0,
|
relay_frps_connections: 0,
|
||||||
relay_frps_proxy_count: 0,
|
relay_frps_proxy_count: 0,
|
||||||
geo_name: '',
|
geo_name: '',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type NavigationIconKey =
|
|||||||
| 'origin'
|
| 'origin'
|
||||||
| 'domain'
|
| 'domain'
|
||||||
| 'certificate'
|
| 'certificate'
|
||||||
|
| 'pages'
|
||||||
| 'proxy'
|
| 'proxy'
|
||||||
| 'waf'
|
| 'waf'
|
||||||
| 'release'
|
| 'release'
|
||||||
|
|||||||
Reference in New Issue
Block a user