From 2c74d042edc8ecc235f292408e3e6d87498abff5 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 3 Jun 2026 17:09:02 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=B0=E5=A2=9E]=20OpenFlare=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/agent-design.md | 7 +- docs/design/architecture.md | 27 +- docs/design/index.md | 18 +- docs/guildline/development-constraints.md | 16 + docs/reference/configuration.md | 4 + openflare_agent/cmd/agent/main.go | 5 +- openflare_agent/internal/config/config.go | 10 + openflare_agent/internal/httpclient/client.go | 18 + openflare_agent/internal/nginx/manager.go | 11 + openflare_agent/internal/sync/pages.go | 273 ++++++++ openflare_agent/internal/sync/service.go | 9 + openflare_agent/internal/sync/service_test.go | 117 +++- openflare_server/controller/pages.go | 162 +++++ openflare_server/controller/response.go | 6 +- ...e_202606030001_add_pages_static_hosting.go | 61 ++ openflare_server/model/goose/migrations.go | 1 + openflare_server/model/main.go | 3 + openflare_server/model/pages.go | 77 +++ openflare_server/model/proxy_route.go | 2 + openflare_server/router/api-router.go | 15 + openflare_server/service/config_version.go | 84 ++- openflare_server/service/pages.go | 635 ++++++++++++++++++ openflare_server/service/pages_test.go | 226 +++++++ openflare_server/service/proxy_route.go | 39 ++ .../utils/render/openresty/render.go | 120 +++- .../utils/render/openresty/types.go | 66 +- .../web/app/(dashboard)/pages/page.tsx | 5 + .../components/layout/dashboard-sidebar.tsx | 9 + .../web/features/pages/api/pages.ts | 66 ++ .../features/pages/components/pages-page.tsx | 364 ++++++++++ openflare_server/web/features/pages/types.ts | 35 + .../components/proxy-route-config-page.tsx | 86 ++- .../components/proxy-route-create-drawer.tsx | 101 ++- .../web/features/proxy-routes/helpers.ts | 11 +- .../web/features/proxy-routes/types.ts | 6 +- .../web/lib/constants/navigation.ts | 5 + .../web/tests/unit/nodes-utils.test.ts | 1 + openflare_server/web/types/navigation.ts | 1 + 38 files changed, 2630 insertions(+), 72 deletions(-) create mode 100644 openflare_agent/internal/sync/pages.go create mode 100644 openflare_server/controller/pages.go create mode 100644 openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go create mode 100644 openflare_server/model/pages.go create mode 100644 openflare_server/service/pages.go create mode 100644 openflare_server/service/pages_test.go create mode 100644 openflare_server/web/app/(dashboard)/pages/page.tsx create mode 100644 openflare_server/web/features/pages/api/pages.ts create mode 100644 openflare_server/web/features/pages/components/pages-page.tsx create mode 100644 openflare_server/web/features/pages/types.ts diff --git a/docs/design/agent-design.md b/docs/design/agent-design.md index 8a324bf2..351e5e9b 100644 --- a/docs/design/agent-design.md +++ b/docs/design/agent-design.md @@ -98,10 +98,11 @@ Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置 * `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。 * `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。 * `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. 精细化的重载动作 1. **备份当前配置**:在写入新文件之前,Agent 会将现有的配置文件复制到 `.backup` 临时目录下,保留完整的现场快照。 -2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`)替换为本地实际运行路径。 +2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`、`__OPENFLARE_PAGES_DIR__`)替换为本地实际运行路径。 3. **语法校验**:调用 `openresty -t -c ` 进行严格的语法测试。 4. **平滑重载**:若校验通过,将新配置移至正式路径,执行 `openresty -s reload`。若 OpenResty 处于未启动状态,则使用当前配置拉起进程。 5. **捕获异常**:校验或重载失败时,Agent 会截获标准错误输出(stderr),提取前 2000 个字符的详细报错信息。 @@ -117,7 +118,7 @@ OpenFlare 摒弃了动态 Patch 节点配置的落后方式,采用 **不可变 ``` ### 1. 核心设计原则 -* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。 +* **完整发布**:每次发布均是对当前控制面所有启用路由、证书、Pages 部署引用、全局与局部 WAF 规则进行一次性全量编译,生成带唯一 `checksum` 的完整版本。 * **版本格式**:采用 `YYYYMMDD-NNN` 递增格式,确保版本历史直观、具备单调递增性。 * **全局单激活版本**:系统同时只有一个处于 `active` 状态的全局配置版本。回滚时无需逆向打补丁,只需将历史某个健康版本的状态改为 `active`,Agent 重新拉取应用即可。 @@ -171,4 +172,4 @@ graph TD 1. **零特权指令通道**:Server 绝对禁止向 Agent 传递任何任意 shell 命令或远程执行脚本(如 exec/eval 等)。所有系统控制原语(如启动、停止、重载、更新)必须硬编码在 Agent 二进制内部。 2. **严格的 Token 过滤与前缀验证**:Agent 侧向 Server 请求资源时,接口端点固定以 `/api/agent/` 为前缀,并强制携带 `X-Agent-Token` 进行签名或令牌核验。 -3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。 \ No newline at end of file +3. **节点自治原则**:Agent 须具备完备的离线工作能力。在与 Server 失去连接期间,本地 OpenResty 必须依靠本地已落地的配置保持反向代理服务的绝对正常运行。 diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 3d4b69bd..021acd5e 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -48,13 +48,27 @@ OpenFlared (frpc) <-- 内网服务器 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、配置渲染、版本发布、数据存储与聚合查询 | -| Agent | 注册、心跳、同步、写入文件、校验、reload、失败回滚、自更新与轻量采集 | -| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证与反向代理 | +| Server | 管理端 UI、管理 API、Agent/Relay/Client API、配置渲染、版本发布、Pages 部署包存储、数据存储与聚合查询 | +| Agent | 注册、心跳、同步、写入文件、Pages 部署包拉取与解压、校验、reload、失败回滚、自更新与轻量采集 | +| OpenResty | 接收真实流量,按 OpenFlare 渲染的配置执行 WAF、PoW、认证、反向代理与 Pages 静态文件服务 | | OpenFlareRelay | 管理 frps 进程生命周期,提供隧道中继服务,通过心跳接收 frps 配置 | | OpenFlared | 管理 frpc 进程(可多个),连接 Relay 中继,将流量转发到内网服务 | | Frontend | 管理网站配置、WAF、源站、证书、节点、Tunnel、版本、用户、设置与观测页面 | @@ -71,6 +85,8 @@ Internal Service (192.168.x.x) Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存控制面状态、生成完整配置版本,并通过 Agent API 让节点主动拉取。 +Pages 静态托管场景中,Server 保存 Pages 项目、不可变部署元数据、文件清单和 zip 部署包;发布版本只记录部署引用和 checksum,不把大体积静态资源写入 `config_versions`。 + ## Agent `openflare_agent` 是 Go 单体程序: @@ -79,6 +95,7 @@ Server 不直接 SSH 到节点,也不在线修改节点文件。它只保存 * 启动后读取或生成本地节点信息。 * 周期性 heartbeat,上报状态并获取激活版本摘要。 * 发现新版本后拉取配置、备份旧文件、写入新文件、校验并 reload。 +* 当激活配置引用 Pages 部署时,先按部署 ID 下载 zip 包,校验 checksum,解压到本地 `pages_dir` 并切换当前部署目录。 * 应用失败时尝试恢复运行并回滚。 * 维护 WAF GeoIP mmdb,启动时写入内置初始库,并按配置定期更新。 @@ -118,6 +135,7 @@ Browser -> Frontend -> /api/* -> controller -> service -> model -> database ```text Agent HTTP heartbeat -> Server 返回激活版本摘要 Agent 发现新版本 -> 拉取配置详情 +Agent 确保 Pages 部署包已下载、校验并解压 (如配置引用 Pages) Agent 写入主配置 / 路由配置 / 证书 / Lua 资源 / WAF 运行时配置 Agent 执行 OpenResty 校验与 reload Agent 上报应用结果 @@ -180,6 +198,9 @@ WAF IP 组由 Server 管理。手动 IP 组直接保存 IP/IP 段列表;自动 * `proxy_routes` * `origins` * `config_versions` +* `pages_projects` +* `pages_deployments` +* `pages_deployment_files` * `nodes` * `tunnels` * `auth_sources` diff --git a/docs/design/index.md b/docs/design/index.md index afa57910..4989e1c0 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -32,6 +32,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre | 管理端前端 | 基于 Next.js 的正式管理端 | | 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 | | 内网穿透 | 通过 TunnelRelay 节点与 OpenFlared 客户端,将内网 HTTP 服务安全暴露到公网,复用 Agent 的 HTTPS/WAF 能力 | +| Pages 静态托管 | 以 Pages 项目管理静态站点部署包,发布后由边缘 Agent 拉取并在本地 OpenResty 静态服务 | 默认工作方式: @@ -52,6 +53,7 @@ OpenFlare 当前不定位为通用日志平台、服务网格、Kubernetes Ingre | 证书托管 | 为不同域名绑定 TLS 证书 | | 基础观测 | 查看节点状态、请求聚合、访问分析和健康事件 | | 内网穿透 | 通过 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`。 * 单上游允许附带 base path 或 query,并在 `proxy_pass` 中追加。多上游限定为纯 `scheme://host[:port]` 结构,且同一规则内的协议必须一致。 * `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头。 * 所有直连类型上游地址都必须为合法的 `http://` 或 `https://`。 * 内网穿透类型上游必须关联有效 `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、外部对象存储或多租户隔离。 ## 内网穿透约束 diff --git a/docs/guildline/development-constraints.md b/docs/guildline/development-constraints.md index 48ef3c9b..41386778 100644 --- a/docs/guildline/development-constraints.md +++ b/docs/guildline/development-constraints.md @@ -61,6 +61,7 @@ Frontend: ### 1. 当前有效实体 * **核心配置与反代**:`proxy_routes` (网站配置), `origins` (源站), `config_versions` (配置版本), `tls_certificates` (证书), `managed_domains` (托管域名). +* **Pages 静态托管**:`pages_projects` (Pages 项目), `pages_deployments` (不可变部署), `pages_deployment_files` (部署文件清单). * **节点与状态**:`nodes` (节点), `node_system_profiles` (系统概况), `apply_logs` (应用日志). * **内网穿透**:`tunnels` (隧道客户端), `tunnel_tokens` (隧道认证令牌,可选持久化). * **观测与分析**:`node_request_reports` (请求上报), `node_access_logs` (访问明细), `node_metric_snapshots` (指标快照), `traffic_analytics_rollups` (流量聚合), `node_health_events` (健康事件). @@ -94,6 +95,12 @@ Frontend: * 必须指定 `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 路由。 +* **Pages 与上游关联**: + * `proxy_routes.upstream_type = 'pages'` 时,必须指定 `pages_project_id`。 + * 被引用的 Pages 项目必须启用,且必须存在当前激活部署。 + * Pages 部署包必须作为 Server 本地文件保存,数据库只保存部署元数据与文件清单;禁止把静态资源内容写入 `config_versions.support_files_json`。 + * 发布配置时,Server 将 Pages 上游渲染为 OpenResty `root` + `try_files` 静态服务,并保留网站规则已有的 HTTPS、WAF、PoW、Basic Auth、限流与缓存配置。 + * **TunnelRelay 节点配置**: * `nodes.node_type = 'tunnel_relay'` 时,新增字段 `relay_bind_port`、`relay_vhost_http_port`、`relay_auth_token` 必须有合理默认值。 * `relay_bind_port` 默认 7000,`relay_vhost_http_port` 默认 8080。 @@ -163,6 +170,11 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是 - CRUD tunnel 实体(创建、查询、更新、删除)。 - Token 管理(生成、轮换)。 - 强制同步(触发 Client 立即拉取新配置)。 +* **Admin Pages 管理 API** - `/api/pages/*`,要求 Admin Session。 + - CRUD Pages 项目。 + - 上传 zip 部署包、查看部署历史、激活部署、删除非激活部署。 +* **Agent Pages 下载 API** - `/api/agent/pages/*`,使用 `X-Agent-Token` 认证。 + - Agent 仅能按激活配置引用的部署 ID 拉取静态部署包,不提供任意文件读取或远程命令入口。 * 总览与节点详情优先使用专用聚合接口。 * 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。 * 管理端继续复用现有登录、角色与 Session。 @@ -184,6 +196,9 @@ v1-v7 视为历史初始基线,不再维护逐版本升级文件。v8-v17 是 * 读取 WAF 规则组、规则组引用的 IP 组与网站绑定关系,并在发布快照中保存可回放数据。 * 自动型 WAF IP 组只能由 Server 定时任务读取请求日志并执行 Expr 布尔规则,OpenResty Lua 与 Agent 不得直接访问请求日志库或执行自动挖掘逻辑。 * 发布版本不得展开 WAF IP 组成员;Agent 必须通过独立的 IP 组 checksum 差异同步和 WebSocket 增量广播维护本地 `waf_ip_groups.json`。 +* **Pages 配置扩展**:区分上游类型,为 `upstream_type = 'pages'` 的代理规则生成 Pages 部署快照。 + * OpenResty 侧:将 Pages 上游渲染为本地静态目录 `root` 与 `try_files`,不得渲染 `proxy_pass`。 + * Agent 侧:在应用 OpenResty 配置前,必须确保引用的 Pages 部署包已下载、checksum 校验通过并解压到 `pages_dir`。 * **内网穿透配置扩展**:区分上游类型,为 `upstream_type = 'tunnel'` 的代理规则生成独立的 tunnel 配置数据。 * OpenResty 侧:将 tunnel 上游自动渲染为 `http://127.0.0.1:{relay_vhost_port}`,必须保留原始 `Host` 请求头。 * Tunnel 侧:为每个 Client 生成完整的 relay 列表与 frpc 代理定义(frpc proxy 配置)。 @@ -209,6 +224,7 @@ Agent 必须满足: * 发现新版本时先备份旧文件。 * 写入主配置、路由配置与必要证书文件。 * 写入 WAF/PoW 运行时配置,并确保 WAF Lua 资源由 Agent 统一管理。 +* 如果激活配置引用 Pages 部署,必须通过 Agent API 拉取部署包,校验配置快照中的 SHA-256 checksum,安全解压并原子切换本地当前部署目录;zip 路径不得逃逸 `pages_dir`,不得接受符号链接。 * WAF IP 组同步必须按组增量更新,不得在每次心跳或每次同步中传输全部 IP 组。 * 写入新配置后执行 `openresty -t -c `,再 reload;reload 发现运行时未启动时允许直接启动 OpenResty。 * 周期性运行时健康检查不得调用 `openresty -t`,避免健康探针触发 upstream 域名同步解析;应优先请求本地 `openresty_observability_port` 上的 `/openflare/stub_status`,以 HTTP `200 OK` 作为 OpenResty 主进程和 worker 正在提供服务的判断依据。 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f60f5a1c..64f94e62 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -168,6 +168,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 | `OPENFLARE_NODE_IP` | 节点 IP,可覆盖 `agent.json` | 空 | | `OPENFLARE_DATA_DIR` | Agent 数据目录,可覆盖 `agent.json` | 空 | | `OPENFLARE_OPENRESTY_PATH` | OpenResty 二进制路径,可覆盖 `agent.json` | 空 | +| `OPENFLARE_PAGES_DIR` | Pages 静态部署目录,可覆盖 `agent.json` | 空 | | `OPENFLARE_HEARTBEAT_INTERVAL` | 心跳间隔,可覆盖 `agent.json` | 空 | | `OPENFLARE_REQUEST_TIMEOUT` | 请求超时,可覆盖 `agent.json` | 空 | | `OPENFLARE_OPENRESTY_OBSERVABILITY_PORT` | 本地观测端口,可覆盖 `agent.json` | 空 | @@ -201,6 +202,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 | `lua_dir` | Lua 脚本与静态资源写入目录 | 否 | `data_dir/etc/nginx/lua` | | `openresty_lua_dir` | OpenResty 配置中读取 Lua 的目录 | 否 | 同 `lua_dir` | | `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_update_interval` | WAF GeoIP mmdb 更新间隔 | 否 | `86400000` 毫秒 | | `mmdb_download_url` | WAF GeoIP mmdb 下载地址 | 否 | 内置 GeoLite2 Country 下载地址 | @@ -218,6 +220,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 * 未配置 `openresty_path` 时默认调用 `openresty`。 * Agent 周期性健康检查会请求 `http://127.0.0.1:/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c `。 * Agent 会初始化并定期更新 `mmdb_path`,供 OpenResty WAF Lua 执行国家级地域规则;更新失败只记录警告,不阻断同步或 reload。 +* 当激活配置引用 Pages 部署时,Agent 会在应用 OpenResty 配置前,将部署包下载、校验并解压到 `pages_dir`,OpenResty 通过该目录服务静态文件。 * 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。 * Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。 * Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。 @@ -334,6 +337,7 @@ go run . "cert_dir": "/var/lib/openflare-agent/etc/nginx/certs", "lua_dir": "/var/lib/openflare-agent/etc/nginx/lua", "runtime_config_dir": "/var/lib/openflare-agent/etc/openflare", + "pages_dir": "/var/lib/openflare-agent/var/lib/openflare/pages", "heartbeat_interval": 10000, "request_timeout": 10000 } diff --git a/openflare_agent/cmd/agent/main.go b/openflare_agent/cmd/agent/main.go index 90d32f82..e4ab8fd9 100644 --- a/openflare_agent/cmd/agent/main.go +++ b/openflare_agent/cmd/agent/main.go @@ -71,6 +71,7 @@ func main() { LuaDir: cfg.LuaDir, NginxLuaDir: cfg.OpenrestyLuaDir, RuntimeConfigDir: cfg.RuntimeConfigDir, + PagesDir: cfg.PagesDir, OpenrestyObservabilityListen: nginx.ObservabilityListenAddress(cfg.OpenrestyObservabilityPort), OpenrestyObservabilityPort: cfg.OpenrestyObservabilityPort, OpenrestyResolverDirective: "", @@ -89,12 +90,14 @@ func main() { slog.Error("ensure managed lua assets failed", "error", err) os.Exit(1) } + syncService := syncservice.New(client, runtimeManager, stateStore) + syncService.SetPagesDir(cfg.PagesDir) runner := &agent.Runner{ Config: cfg, StateStore: stateStore, ObservabilityBuffer: observabilityBuffer, HeartbeatService: heartbeat.New(client), - SyncService: syncservice.New(client, runtimeManager, stateStore), + SyncService: syncService, Updater: updater.New(), RuntimeManager: runtimeManager, WebSocketService: wsClient, diff --git a/openflare_agent/internal/config/config.go b/openflare_agent/internal/config/config.go index 280cbf45..988e94ec 100644 --- a/openflare_agent/internal/config/config.go +++ b/openflare_agent/internal/config/config.go @@ -23,6 +23,7 @@ const ( defaultCertDirRelativePath = "etc/nginx/certs" defaultLuaDirRelativePath = "etc/nginx/lua" defaultRuntimeConfigDirRelativePath = "etc/openflare" + defaultPagesDirRelativePath = "var/lib/openflare/pages" defaultMMDBRelativePath = "etc/openflare/GeoLite2-Country.mmdb" defaultAccessLogRelativePath = "var/log/openflare/access.log" defaultStateRelativePath = "var/lib/openflare/agent-state.json" @@ -57,6 +58,7 @@ type Config struct { LuaDir string `json:"lua_dir"` OpenrestyLuaDir string `json:"openresty_lua_dir"` RuntimeConfigDir string `json:"runtime_config_dir"` + PagesDir string `json:"pages_dir"` MMDBPath string `json:"mmdb_path"` MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"` MMDBDownloadURL string `json:"mmdb_download_url"` @@ -86,6 +88,7 @@ type configFile struct { LuaDir string `json:"lua_dir"` OpenrestyLuaDir string `json:"openresty_lua_dir"` RuntimeConfigDir string `json:"runtime_config_dir"` + PagesDir string `json:"pages_dir"` MMDBPath string `json:"mmdb_path"` MMDBUpdateInterval MillisecondDuration `json:"mmdb_update_interval"` MMDBDownloadURL string `json:"mmdb_download_url"` @@ -128,6 +131,7 @@ func Load(path string) (*Config, error) { LuaDir: file.LuaDir, OpenrestyLuaDir: file.OpenrestyLuaDir, RuntimeConfigDir: file.RuntimeConfigDir, + PagesDir: file.PagesDir, MMDBPath: file.MMDBPath, MMDBUpdateInterval: file.MMDBUpdateInterval, MMDBDownloadURL: file.MMDBDownloadURL, @@ -190,6 +194,9 @@ func applyDefaults(cfg *Config, baseDir string) { if cfg.RuntimeConfigDir == "" { cfg.RuntimeConfigDir = joinManagedPath(cfg.DataDir, defaultRuntimeConfigDirRelativePath) } + if cfg.PagesDir == "" { + cfg.PagesDir = joinManagedPath(cfg.DataDir, defaultPagesDirRelativePath) + } if cfg.MMDBPath == "" { cfg.MMDBPath = joinManagedPath(cfg.DataDir, defaultMMDBRelativePath) } @@ -231,6 +238,7 @@ func normalizeManagedPaths(cfg *Config) { &cfg.LuaDir, &cfg.OpenrestyLuaDir, &cfg.RuntimeConfigDir, + &cfg.PagesDir, &cfg.StatePath, &cfg.ObservabilityBufferPath, &cfg.MMDBPath, @@ -251,6 +259,7 @@ func hasEnvConfig() bool { "OPENFLARE_NODE_IP", "OPENFLARE_DATA_DIR", "OPENFLARE_OPENRESTY_PATH", + "OPENFLARE_PAGES_DIR", "OPENFLARE_HEARTBEAT_INTERVAL", "OPENFLARE_REQUEST_TIMEOUT", "OPENFLARE_OPENRESTY_OBSERVABILITY_PORT", @@ -281,6 +290,7 @@ func applyEnvOverrides(cfg *Config) { overrideString("OPENFLARE_NODE_IP", &cfg.NodeIP) overrideString("OPENFLARE_DATA_DIR", &cfg.DataDir) overrideString("OPENFLARE_OPENRESTY_PATH", &cfg.OpenrestyPath) + overrideString("OPENFLARE_PAGES_DIR", &cfg.PagesDir) overrideString("OPENFLARE_MMDB_PATH", &cfg.MMDBPath) overrideString("OPENFLARE_MMDB_DOWNLOAD_URL", &cfg.MMDBDownloadURL) if value := strings.TrimSpace(os.Getenv("OPENFLARE_HEARTBEAT_INTERVAL")); value != "" { diff --git a/openflare_agent/internal/httpclient/client.go b/openflare_agent/internal/httpclient/client.go index f7773de7..16ab44f8 100644 --- a/openflare_agent/internal/httpclient/client.go +++ b/openflare_agent/internal/httpclient/client.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" @@ -86,6 +87,23 @@ func (c *Client) SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGrou 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) { c.token = strings.TrimSpace(token) slog.Debug("http client token updated") diff --git a/openflare_agent/internal/nginx/manager.go b/openflare_agent/internal/nginx/manager.go index 29fb30c5..e585950e 100644 --- a/openflare_agent/internal/nginx/manager.go +++ b/openflare_agent/internal/nginx/manager.go @@ -141,6 +141,7 @@ type Manager struct { LuaDir string NginxLuaDir string RuntimeConfigDir string + PagesDir string OpenrestyObservabilityListen string OpenrestyObservabilityPort int 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, openrestyrender.LuaDirPlaceholder) } + if pagesDir := m.pagesRuntimePath(); pagesDir != "" { + normalizedRoute = strings.ReplaceAll(normalizedRoute, pagesDir, openrestyrender.PagesDirPlaceholder) + } files, err := m.readManagedSupportFiles() if err != nil { 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.PowStaticDirPlaceholder, luaDir+"/pow/static") } + if pagesDir := m.pagesRuntimePath(); pagesDir != "" { + rendered = strings.ReplaceAll(rendered, openrestyrender.PagesDirPlaceholder, pagesDir) + } return rendered } @@ -1258,6 +1265,10 @@ func (m *Manager) luaRuntimePath() string { return filepath.ToSlash(m.NginxLuaDir) } +func (m *Manager) pagesRuntimePath() string { + return filepath.ToSlash(strings.TrimSpace(m.PagesDir)) +} + func checksum(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) diff --git a/openflare_agent/internal/sync/pages.go b/openflare_agent/internal/sync/pages.go new file mode 100644 index 00000000..80d7804e --- /dev/null +++ b/openflare_agent/internal/sync/pages.go @@ -0,0 +1,273 @@ +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 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) + } + for _, item := range reader.File { + relativePath, skip, err := normalizePagesArchivePath(item.Name) + if err != nil { + _ = os.RemoveAll(tmpDir) + return err + } + if skip { + continue + } + 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[:]) +} diff --git a/openflare_agent/internal/sync/service.go b/openflare_agent/internal/sync/service.go index 29909b6a..132b3a2d 100644 --- a/openflare_agent/internal/sync/service.go +++ b/openflare_agent/internal/sync/service.go @@ -25,6 +25,7 @@ const ( type ConfigClient interface { GetActiveConfig(ctx context.Context) (*protocol.ActiveConfigResponse, error) + DownloadPagesDeploymentPackage(ctx context.Context, deploymentID uint) ([]byte, error) ReportApplyLog(ctx context.Context, payload protocol.ApplyLogPayload) error SyncWAFIPGroups(ctx context.Context, payload protocol.WAFIPGroupSyncRequest) (*protocol.WAFIPGroupSyncResponse, error) } @@ -42,6 +43,11 @@ type Service struct { client ConfigClient nginxManager NginxManager 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 { @@ -225,6 +231,9 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, if err != nil { return err } + if err := s.syncPagesDeployments(ctx, config); err != nil { + return err + } mainConfigChecksum := checksumString(rendered.mainConfig) 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) diff --git a/openflare_agent/internal/sync/service_test.go b/openflare_agent/internal/sync/service_test.go index 440bb168..8e29bb10 100644 --- a/openflare_agent/internal/sync/service_test.go +++ b/openflare_agent/internal/sync/service_test.go @@ -1,7 +1,11 @@ package sync import ( + "archive/zip" + "bytes" "context" + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" @@ -19,10 +23,15 @@ type fakeExecutor struct { 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 { - config protocol.ActiveConfigResponse - reports []protocol.ApplyLogPayload - fetchCalls int + config protocol.ActiveConfigResponse + reports []protocol.ApplyLogPayload + pagesPackages map[uint][]byte + fetchCalls int } type fakeManager struct { @@ -67,6 +76,13 @@ func (f *fakeClient) GetActiveConfig(ctx context.Context) (*protocol.ActiveConfi 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 { f.reports = append(f.reports, payload) 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) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ @@ -761,3 +848,27 @@ func TestSyncOnceSkipsFetchWhenHeartbeatChecksumMatches(t *testing.T) { 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[:]) +} diff --git a/openflare_server/controller/pages.go b/openflare_server/controller/pages.go new file mode 100644 index 00000000..c65553b5 --- /dev/null +++ b/openflare_server/controller/pages.go @@ -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) +} diff --git a/openflare_server/controller/response.go b/openflare_server/controller/response.go index 34a4888a..812bee1f 100644 --- a/openflare_server/controller/response.go +++ b/openflare_server/controller/response.go @@ -75,7 +75,11 @@ func decodeOptionalJSONBody(body io.Reader, target any) error { } 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 { respondBadRequest(c, "") return 0, false diff --git a/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go b/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go new file mode 100644 index 00000000..8673c631 --- /dev/null +++ b/openflare_server/model/goose/goose_202606030001_add_pages_static_hosting.go @@ -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"} { + 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 +} diff --git a/openflare_server/model/goose/migrations.go b/openflare_server/model/goose/migrations.go index 89e098e5..f0b6b455 100644 --- a/openflare_server/model/goose/migrations.go +++ b/openflare_server/model/goose/migrations.go @@ -41,6 +41,7 @@ func newGORMMigration(version int64, source string, backend string, ctx Context, func registeredMigrations(backend string, ctx Context) []*presslygoose.Migration { return []*presslygoose.Migration{ migration202606020001(backend, ctx), + migration202606030001(backend, ctx), } } diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index f05be420..861ad62c 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -32,6 +32,9 @@ func registeredModels() []any { &Option{}, &Origin{}, &ProxyRoute{}, + &PagesProject{}, + &PagesDeployment{}, + &PagesDeploymentFile{}, &ConfigVersion{}, &Node{}, diff --git a/openflare_server/model/pages.go b/openflare_server/model/pages.go new file mode 100644 index 00000000..c671ee69 --- /dev/null +++ b/openflare_server/model/pages.go @@ -0,0 +1,77 @@ +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"` + 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 +} diff --git a/openflare_server/model/proxy_route.go b/openflare_server/model/proxy_route.go index 158fb34b..fee4125f 100644 --- a/openflare_server/model/proxy_route.go +++ b/openflare_server/model/proxy_route.go @@ -34,6 +34,7 @@ type ProxyRoute struct { TunnelNodeID *uint `json:"tunnel_node_id" gorm:"index"` TunnelTargetAddr string `json:"tunnel_target_addr" gorm:"size:512"` TunnelTargetProtocol string `json:"tunnel_target_protocol" gorm:"size:16"` + PagesProjectID *uint `json:"pages_project_id" gorm:"index"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -95,6 +96,7 @@ func (route *ProxyRoute) Update() error { "tunnel_node_id": route.TunnelNodeID, "tunnel_target_addr": route.TunnelTargetAddr, "tunnel_target_protocol": route.TunnelTargetProtocol, + "pages_project_id": route.PagesProjectID, }).Error } diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 3b247346..6f146892 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -127,6 +127,20 @@ func SetApiRouter(router *gin.Engine) { originRoute.POST("/:id/update", controller.UpdateOrigin) 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.Use(middleware.AdminAuth()) { @@ -227,6 +241,7 @@ func SetApiRouter(router *gin.Engine) { authorizedRoute.GET("/ws", controller.AgentWebSocket) authorizedRoute.POST("/nodes/heartbeat", controller.AgentHeartbeat) 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("/apply-logs", controller.AgentReportApplyLog) } diff --git a/openflare_server/service/config_version.go b/openflare_server/service/config_version.go index 8c15ba7b..e54554ff 100644 --- a/openflare_server/service/config_version.go +++ b/openflare_server/service/config_version.go @@ -93,6 +93,19 @@ type snapshotRoute struct { TunnelNodeID *uint `json:"tunnel_node_id,omitempty"` TunnelTargetAddr string `json:"tunnel_target_addr,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"` + LocalRoot string `json:"local_root"` } type snapshotWAFRuleGroup struct { @@ -515,12 +528,23 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { var tunnelNodeID *uint var tunnelTargetAddr string var tunnelTargetProtocol string + var pagesProjectID *uint + var pagesDeployment *snapshotPagesDeployment if upstreamType == "tunnel" { originURL = resolveTunnelOpenRestyUpstreamURL() upstreams = []string{originURL} tunnelNodeID = route.TunnelNodeID tunnelTargetAddr = strings.TrimSpace(route.TunnelTargetAddr) 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) if err != nil { @@ -564,11 +588,46 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) { TunnelNodeID: tunnelNodeID, TunnelTargetAddr: tunnelTargetAddr, TunnelTargetProto: tunnelTargetProtocol, + PagesProjectID: pagesProjectID, + PagesDeployment: pagesDeployment, }) } 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, + LocalRoot: fmt.Sprintf("%s/deployments/%d/current", openrestyrender.PagesDirPlaceholder, deployment.ID), + }, nil +} + func resolveTunnelOpenRestyUpstreamURL() string { relayNodes, err := model.ListNodesByType("tunnel_relay") if err == nil && len(relayNodes) > 0 { @@ -819,10 +878,18 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute { if routes[index].UpstreamType == "tunnel" { routes[index].TunnelTargetAddr = strings.TrimSpace(routes[index].TunnelTargetAddr) 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 { routes[index].TunnelNodeID = nil routes[index].TunnelTargetAddr = "" routes[index].TunnelTargetProto = "" + routes[index].PagesProjectID = nil + routes[index].PagesDeployment = nil } } return routes @@ -849,7 +916,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo } 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 } if len(left.Domains) != len(right.Domains) { @@ -890,6 +957,21 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool { 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 { leftJSON, err := json.Marshal(left) if err != nil { diff --git a/openflare_server/service/pages.go b/openflare_server/service/pages.go new file mode 100644 index 00000000..63731939 --- /dev/null +++ b/openflare_server/service/pages.go @@ -0,0 +1,635 @@ +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 = 25 * 1024 * 1024 + defaultPagesEntryFile = "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"` +} + +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"` + 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, + }).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 + 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, + 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 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 inspectPagesZip(zipPath string, entryFile string) (*pagesDeploymentManifest, error) { + reader, err := zip.OpenReader(zipPath) + if err != nil { + return nil, errors.New("Pages 部署包不是有效 zip 文件") + } + defer reader.Close() + 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 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() +} diff --git a/openflare_server/service/pages_test.go b/openflare_server/service/pages_test.go new file mode 100644 index 00000000..5589d2ff --- /dev/null +++ b/openflare_server/service/pages_test.go @@ -0,0 +1,226 @@ +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, + }) + if err != nil { + t.Fatalf("CreatePagesProject failed: %v", err) + } + uploadHeader := multipartFileHeader(t, "site.zip", testPagesZip(t, map[string]string{ + "index.html": "

Hello Pages

", + "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.RenderedConfig, "try_files $uri $uri/ /index.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 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) + } +} diff --git a/openflare_server/service/proxy_route.go b/openflare_server/service/proxy_route.go index f69826f7..53e13156 100644 --- a/openflare_server/service/proxy_route.go +++ b/openflare_server/service/proxy_route.go @@ -66,6 +66,7 @@ type ProxyRouteInput struct { TunnelID *uint `json:"tunnel_id"` TunnelTargetAddr string `json:"tunnel_target_addr"` TunnelTargetProtocol string `json:"tunnel_target_protocol"` + PagesProjectID *uint `json:"pages_project_id"` } type ProxyRouteView struct { @@ -106,6 +107,7 @@ type ProxyRouteView struct { TunnelID *uint `json:"tunnel_id"` TunnelTargetAddr string `json:"tunnel_target_addr"` TunnelTargetProtocol string `json:"tunnel_target_protocol"` + PagesProjectID *uint `json:"pages_project_id"` CreatedAt time.Time `json:"created_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 originURL = "http://127.0.0.1" 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 { originURL, originID, err = resolveProxyRoutePrimaryOrigin(input) if err != nil { @@ -339,10 +348,17 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro route.TunnelNodeID = tunnelNodeID route.TunnelTargetAddr = strings.TrimSpace(input.TunnelTargetAddr) route.TunnelTargetProtocol = normalizeTunnelTargetProtocol(input.TunnelTargetProtocol) + route.PagesProjectID = nil + } else if upstreamType == "pages" { + route.TunnelNodeID = nil + route.TunnelTargetAddr = "" + route.TunnelTargetProtocol = "" + route.PagesProjectID = input.PagesProjectID } else { route.TunnelNodeID = nil route.TunnelTargetAddr = "" route.TunnelTargetProtocol = "" + route.PagesProjectID = nil } return route, nil } @@ -434,6 +450,7 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) { TunnelID: route.TunnelNodeID, TunnelTargetAddr: route.TunnelTargetAddr, TunnelTargetProtocol: route.TunnelTargetProtocol, + PagesProjectID: route.PagesProjectID, CreatedAt: route.CreatedAt, UpdatedAt: route.UpdatedAt, }, 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 { siteName := strings.TrimSpace(raw) if siteName != "" { @@ -1291,6 +1328,8 @@ func normalizeUpstreamType(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "tunnel": return "tunnel" + case "pages": + return "pages" default: return "direct" } diff --git a/openflare_server/utils/render/openresty/render.go b/openflare_server/utils/render/openresty/render.go index 6cf85fb3..4aa883ef 100644 --- a/openflare_server/utils/render/openresty/render.go +++ b/openflare_server/utils/render/openresty/render.go @@ -84,6 +84,66 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er if displayName == "" { 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 if len(upstreams) == 0 && strings.TrimSpace(route.OriginURL) != "" { upstreams = []string{route.OriginURL} @@ -92,12 +152,6 @@ func RenderRouteConfig(doc Document, certificateFiles []SupportFile) (string, er if upstreamConfig.UsesNamedUpstream { 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 { builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, doc.OpenRestyConfig)) continue @@ -372,6 +426,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)) } +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 { 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 +446,43 @@ 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)) } +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(" try_files $uri $uri/ /index.html;\n") + } 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 renderProxyHeaderBlock(originURL string, originHost string, customHeaders []CustomHeader, upstreamConfig routeUpstreamConfig, cfg ConfigSnapshot) string { var builder strings.Builder if strings.TrimSpace(originHost) != "" { @@ -543,6 +638,15 @@ func buildRouteUpstreamConfig(route Route, upstreams []string) routeUpstreamConf 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 { var builder strings.Builder builder.WriteString(fmt.Sprintf("upstream %s {\n", upstreamConfig.Name)) @@ -787,6 +891,10 @@ func quoteNginxStringLiteral(value string) string { return fmt.Sprintf(`"%s"`, escaped) } +func filepathToNginxPath(value string) string { + return strings.ReplaceAll(strings.TrimSpace(value), `\`, `/`) +} + func escapeNginxString(value string) string { escaped := strings.ReplaceAll(value, `\`, `\\`) escaped = strings.ReplaceAll(escaped, `"`, `\"`) diff --git a/openflare_server/utils/render/openresty/types.go b/openflare_server/utils/render/openresty/types.go index 7436f851..9ef89f7d 100644 --- a/openflare_server/utils/render/openresty/types.go +++ b/openflare_server/utils/render/openresty/types.go @@ -9,6 +9,7 @@ const ( ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__" ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__" PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__" + PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__" SourceConfigFileName = "openresty_config.json" ) @@ -90,32 +91,45 @@ type PoWConfig struct { } type Route struct { - ID uint `json:"id,omitempty"` - SiteName string `json:"site_name,omitempty"` - Domain string `json:"domain"` - Domains []string `json:"domains,omitempty"` - OriginURL string `json:"origin_url"` - OriginHost string `json:"origin_host,omitempty"` - Upstreams []string `json:"upstreams,omitempty"` - Enabled bool `json:"enabled"` - EnableHTTPS bool `json:"enable_https"` - CertID *uint `json:"cert_id,omitempty"` - CertIDs []uint `json:"cert_ids,omitempty"` - DomainCertIDs []uint `json:"domain_cert_ids,omitempty"` - RedirectHTTP bool `json:"redirect_http"` - LimitConnPerServer int `json:"limit_conn_per_server,omitempty"` - LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"` - LimitRate string `json:"limit_rate,omitempty"` - CacheEnabled bool `json:"cache_enabled"` - CachePolicy string `json:"cache_policy,omitempty"` - CacheRules []string `json:"cache_rules,omitempty"` - CustomHeaders []CustomHeader `json:"custom_headers,omitempty"` - PoWEnabled bool `json:"pow_enabled,omitempty"` - PoWConfig *PoWConfig `json:"pow_config,omitempty"` - BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"` - BasicAuthUsername string `json:"basic_auth_username,omitempty"` - BasicAuthPassword string `json:"basic_auth_password,omitempty"` - Remark string `json:"remark,omitempty"` + ID uint `json:"id,omitempty"` + SiteName string `json:"site_name,omitempty"` + Domain string `json:"domain"` + Domains []string `json:"domains,omitempty"` + OriginURL string `json:"origin_url"` + OriginHost string `json:"origin_host,omitempty"` + Upstreams []string `json:"upstreams,omitempty"` + Enabled bool `json:"enabled"` + EnableHTTPS bool `json:"enable_https"` + CertID *uint `json:"cert_id,omitempty"` + CertIDs []uint `json:"cert_ids,omitempty"` + DomainCertIDs []uint `json:"domain_cert_ids,omitempty"` + RedirectHTTP bool `json:"redirect_http"` + LimitConnPerServer int `json:"limit_conn_per_server,omitempty"` + LimitConnPerIP int `json:"limit_conn_per_ip,omitempty"` + LimitRate string `json:"limit_rate,omitempty"` + CacheEnabled bool `json:"cache_enabled"` + CachePolicy string `json:"cache_policy,omitempty"` + CacheRules []string `json:"cache_rules,omitempty"` + CustomHeaders []CustomHeader `json:"custom_headers,omitempty"` + PoWEnabled bool `json:"pow_enabled,omitempty"` + PoWConfig *PoWConfig `json:"pow_config,omitempty"` + BasicAuthEnabled bool `json:"basic_auth_enabled,omitempty"` + BasicAuthUsername string `json:"basic_auth_username,omitempty"` + BasicAuthPassword string `json:"basic_auth_password,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"` + LocalRoot string `json:"local_root"` } type WAFRuleGroup struct { diff --git a/openflare_server/web/app/(dashboard)/pages/page.tsx b/openflare_server/web/app/(dashboard)/pages/page.tsx new file mode 100644 index 00000000..3cd8a289 --- /dev/null +++ b/openflare_server/web/app/(dashboard)/pages/page.tsx @@ -0,0 +1,5 @@ +import { PagesPage } from '@/features/pages/components/pages-page'; + +export default function Page() { + return ; +} diff --git a/openflare_server/web/components/layout/dashboard-sidebar.tsx b/openflare_server/web/components/layout/dashboard-sidebar.tsx index efa4926c..b3106872 100644 --- a/openflare_server/web/components/layout/dashboard-sidebar.tsx +++ b/openflare_server/web/components/layout/dashboard-sidebar.tsx @@ -80,6 +80,15 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) { ); + case 'pages': + return ( + + + + + + + ); case 'waf': return ; case 'release': diff --git a/openflare_server/web/features/pages/api/pages.ts b/openflare_server/web/features/pages/api/pages.ts new file mode 100644 index 00000000..43226e70 --- /dev/null +++ b/openflare_server/web/features/pages/api/pages.ts @@ -0,0 +1,66 @@ +import { apiRequest } from '@/lib/api/client'; + +import type { + PagesDeployment, + PagesProject, + PagesProjectPayload, +} from '@/features/pages/types'; + +export function getPagesProjects() { + return apiRequest('/pages/'); +} + +export function createPagesProject(payload: PagesProjectPayload) { + return apiRequest('/pages/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updatePagesProject(id: number, payload: PagesProjectPayload) { + return apiRequest(`/pages/${id}/update`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function deletePagesProject(id: number) { + return apiRequest(`/pages/${id}/delete`, { + method: 'POST', + }); +} + +export function getPagesDeployments(projectId: number) { + return apiRequest(`/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(`/pages/${projectId}/deployments/upload`, { + method: 'POST', + body: formData, + }); +} + +export function activatePagesDeployment( + projectId: number, + deploymentId: number, +) { + return apiRequest( + `/pages/${projectId}/deployments/${deploymentId}/activate`, + { method: 'POST' }, + ); +} + +export function deletePagesDeployment(projectId: number, deploymentId: number) { + return apiRequest( + `/pages/${projectId}/deployments/${deploymentId}/delete`, + { method: 'POST' }, + ); +} diff --git a/openflare_server/web/features/pages/components/pages-page.tsx b/openflare_server/web/features/pages/components/pages-page.tsx new file mode 100644 index 00000000..a61a9914 --- /dev/null +++ b/openflare_server/web/features/pages/components/pages-page.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState, type FormEvent } from 'react'; + +import { AppCard } from '@/components/ui/app-card'; +import { + activatePagesDeployment, + createPagesProject, + deletePagesDeployment, + deletePagesProject, + 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 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 queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [slug, setSlug] = useState(''); + const [description, setDescription] = useState(''); + const [spaFallbackEnabled, setSpaFallbackEnabled] = useState(false); + + const projectsQuery = useQuery({ + queryKey: projectsQueryKey, + queryFn: getPagesProjects, + }); + + const createMutation = useMutation({ + mutationFn: () => + createPagesProject({ + name, + slug, + description, + enabled: true, + spa_fallback_enabled: spaFallbackEnabled, + }), + onSuccess: () => { + setName(''); + setSlug(''); + setDescription(''); + setSpaFallbackEnabled(false); + queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); + + function handleCreate(event: FormEvent) { + event.preventDefault(); + createMutation.mutate(); + } + + return ( +
+
+

+ OpenFlare Pages +

+

+ 边缘静态站点托管 +

+

+ 创建 Pages 项目,上传已构建的 zip 静态资源包,然后在规则中选择 Pages + 项目作为上游。发布后 Agent 会拉取部署包并在边缘节点本地服务静态文件。 +

+
+ + +
+ + setName(event.target.value)} + required + /> + + + setSlug(event.target.value)} + /> + + + setDescription(event.target.value)} + /> + + +
+ + {createMutation.isPending ? '创建中...' : '创建项目'} + +
+ {createMutation.error ? ( +

+ {createMutation.error.message} +

+ ) : null} + +
+ +
+ {projectsQuery.isLoading ? ( + 正在加载 Pages 项目... + ) : projectsQuery.error ? ( + +

+ {projectsQuery.error.message} +

+
+ ) : (projectsQuery.data ?? []).length === 0 ? ( + +

+ 还没有 Pages 项目。先创建一个项目,再上传静态资源包。 +

+
+ ) : ( + (projectsQuery.data ?? []).map((project) => ( + + )) + )} +
+
+ ); +} + +function PagesProjectCard({ project }: { project: PagesProject }) { + const queryClient = useQueryClient(); + const [file, setFile] = useState(null); + const [entryFile, setEntryFile] = useState('index.html'); + const deploymentsQuery = useQuery({ + queryKey: ['pages-deployments', project.id], + queryFn: () => getPagesDeployments(project.id), + }); + + const uploadMutation = useMutation({ + mutationFn: () => { + if (!file) { + throw new Error('请选择 zip 文件'); + } + return uploadPagesDeployment(project.id, file, entryFile); + }, + onSuccess: () => { + setFile(null); + queryClient.invalidateQueries({ + queryKey: ['pages-deployments', project.id], + }); + queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); + const activateMutation = useMutation({ + mutationFn: (deploymentId: number) => + activatePagesDeployment(project.id, deploymentId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['pages-deployments', project.id], + }); + queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); + const deleteDeploymentMutation = useMutation({ + mutationFn: (deploymentId: number) => + deletePagesDeployment(project.id, deploymentId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['pages-deployments', project.id], + }); + queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); + const deleteProjectMutation = useMutation({ + mutationFn: () => deletePagesProject(project.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectsQueryKey }); + }, + }); + + return ( + { + if (window.confirm(`确认删除 Pages 项目 ${project.name} 吗?`)) { + deleteProjectMutation.mutate(); + } + }} + > + 删除项目 + + } + > +
+
+
+

+ 当前激活部署 +

+

+ {project.active_deployment + ? `#${project.active_deployment.deployment_number} · ${project.active_deployment.checksum.slice(0, 12)}` + : '暂无激活部署'} +

+

+ 激活时间:{formatDate(project.active_deployment?.activated_at)} +

+
+ +
+ + setFile(event.target.files?.[0] ?? null)} + /> + + + setEntryFile(event.target.value)} + /> + + uploadMutation.mutate()} + > + {uploadMutation.isPending ? '上传中...' : '上传部署'} + + {uploadMutation.error ? ( +

+ {uploadMutation.error.message} +

+ ) : null} +
+
+ +
+

+ 部署历史 +

+ {deploymentsQuery.isLoading ? ( +

+ 加载中... +

+ ) : (deploymentsQuery.data ?? []).length === 0 ? ( +

+ 暂无部署。上传 zip 后再激活部署。 +

+ ) : ( +
+ {(deploymentsQuery.data ?? []).map((deployment) => ( +
+
+

+ #{deployment.deployment_number}{' '} + {deployment.status === 'active' ? '· 已激活' : ''} +

+

+ {deployment.checksum.slice(0, 16)} ·{' '} + {deployment.file_count} files ·{' '} + {formatBytes(deployment.total_size)} +

+
+
+ { + if ( + window.confirm( + `确认激活部署 #${deployment.deployment_number} 吗?`, + ) + ) { + activateMutation.mutate(deployment.id); + } + }} + > + 激活 + + { + if ( + window.confirm( + `确认删除部署 #${deployment.deployment_number} 吗?`, + ) + ) { + deleteDeploymentMutation.mutate(deployment.id); + } + }} + > + 删除 + +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/openflare_server/web/features/pages/types.ts b/openflare_server/web/features/pages/types.ts new file mode 100644 index 00000000..d47add88 --- /dev/null +++ b/openflare_server/web/features/pages/types.ts @@ -0,0 +1,35 @@ +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; + 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; +} diff --git a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx index ff048a74..e0242b35 100644 --- a/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx +++ b/openflare_server/web/features/proxy-routes/components/proxy-route-config-page.tsx @@ -15,6 +15,7 @@ import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppCard } from '@/components/ui/app-card'; import { getManagedDomains } from '@/features/managed-domains/api/managed-domains'; +import { getPagesProjects } from '@/features/pages/api/pages'; import { getProxyRoute, updateProxyRoute, @@ -157,12 +158,13 @@ const rateLimitSchema = z const reverseProxySchema = z .object({ - upstream_type: z.enum(['direct', 'tunnel']), + upstream_type: z.enum(['direct', 'tunnel', 'pages']), origin_urls_text: z.string().trim(), origin_host: z.string(), tunnel_id: z.string().optional(), tunnel_target_addr: z.string().trim().optional(), tunnel_target_protocol: z.enum(['http', 'https']).optional(), + pages_project_id: z.string().optional(), custom_headers_text: z.string(), remark: z.string().max(255, '备注不能超过 255 个字符'), }) @@ -184,7 +186,7 @@ const reverseProxySchema = z }); } } - } else { + } else if (value.upstream_type === 'tunnel') { if (!value.tunnel_id) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -199,6 +201,12 @@ const reverseProxySchema = z 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); @@ -552,8 +560,17 @@ function ReverseProxySection({ queryKey: ['nodes'], queryFn: getNodes, }); - - const tunnelClients = (tunnelsQuery.data ?? []).filter((node) => node.node_type === 'tunnel_client'); + const pagesProjectsQuery = useQuery({ + 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({ resolver: zodResolver(reverseProxySchema), @@ -564,6 +581,7 @@ function ReverseProxySection({ tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '', tunnel_target_addr: route.tunnel_target_addr || '', 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), remark: route.remark || '', }, @@ -577,6 +595,7 @@ function ReverseProxySection({ tunnel_id: route.tunnel_node_id ? String(route.tunnel_node_id) : '', tunnel_target_addr: route.tunnel_target_addr || '', 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), remark: route.remark || '', }); @@ -609,12 +628,17 @@ function ReverseProxySection({ originPort = primaryOrigin.port; originUri = primaryOrigin.uri; upstreams = urls.slice(1); - } else { + } else if (values.upstream_type === 'tunnel') { originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`; originScheme = values.tunnel_target_protocol as 'http' | 'https'; originAddress = values.tunnel_target_addr || ''; + } else { + originUrl = 'http://127.0.0.1'; + originScheme = 'http'; + originAddress = '127.0.0.1'; + originPort = '80'; } - + const { headers } = parseCustomHeadersText( values.custom_headers_text, ); @@ -632,9 +656,22 @@ function ReverseProxySection({ custom_headers: headers, remark: values.remark.trim(), upstream_type: values.upstream_type, - tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? 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 : '', + tunnel_node_id: + values.upstream_type === 'tunnel' && values.tunnel_id + ? 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: '反向代理设置已保存。' }, ); @@ -661,6 +698,15 @@ function ReverseProxySection({ /> 内网穿透 (Tunnel) + @@ -679,7 +725,7 @@ function ReverseProxySection({ {...form.register('origin_urls_text')} /> - ) : ( + ) : form.watch('upstream_type') === 'tunnel' ? (
+ ) : ( +
+ + + +
)} 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( () => [ @@ -199,11 +218,11 @@ export function ProxyRouteCreateDrawer({ const selectedCertIDs = normalizeSelectedCertificateIDs( values.domain_rows, ); - + let originUrl = ''; const originHost = ''; let upstreams: string[] = []; - + if (values.upstream_type === 'direct') { const { urls } = parseOriginUrls(values.origin_urls_text); const primaryOrigin = parseOriginUrl(urls[0]); @@ -214,8 +233,10 @@ export function ProxyRouteCreateDrawer({ primaryOrigin.uri, ); upstreams = urls.slice(1); - } else { + } else if (values.upstream_type === 'tunnel') { originUrl = `${values.tunnel_target_protocol}://${values.tunnel_target_addr}`; + } else { + originUrl = 'http://127.0.0.1'; } return createProxyRoute({ @@ -224,10 +245,22 @@ export function ProxyRouteCreateDrawer({ domains, origin_id: null, origin_url: originUrl, - origin_scheme: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).scheme : 'http', - origin_address: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).address : values.tunnel_target_addr || '', - origin_port: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).port : '80', - origin_uri: values.upstream_type === 'direct' ? parseOriginUrl(originUrl).uri : '', + origin_scheme: + values.upstream_type === 'direct' + ? parseOriginUrl(originUrl).scheme + : '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, upstreams, enabled: values.enabled, @@ -249,9 +282,20 @@ export function ProxyRouteCreateDrawer({ basic_auth_enabled: false, remark: values.remark.trim(), upstream_type: values.upstream_type, - tunnel_node_id: values.upstream_type === 'tunnel' && values.tunnel_id ? 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 : '', + tunnel_node_id: + values.upstream_type === 'tunnel' && values.tunnel_id + ? 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) => { @@ -359,6 +403,15 @@ export function ProxyRouteCreateDrawer({ /> 内网穿透 (Tunnel) + @@ -376,7 +429,7 @@ export function ProxyRouteCreateDrawer({ {...form.register('origin_urls_text')} /> - ) : ( + ) : form.watch('upstream_type') === 'tunnel' ? (
+ ) : ( +
+ + + +
)} , ): ProxyRouteMutationPayload { - const primaryOrigin = parseOriginUrl(route.origin_url); + const primaryOrigin = + route.upstream_type === 'pages' + ? parseOriginUrl('http://127.0.0.1') + : parseOriginUrl(route.origin_url); return { site_name: route.site_name, @@ -304,11 +307,17 @@ export function buildPayloadFromRoute( tunnel_node_id: route.tunnel_node_id ?? route.tunnel_id ?? null, tunnel_target_addr: route.tunnel_target_addr || '', tunnel_target_protocol: route.tunnel_target_protocol || '', + pages_project_id: route.pages_project_id ?? null, ...overrides, }; } 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') { const protocol = route.tunnel_target_protocol || 'http'; const target = route.tunnel_target_addr || '未配置目标'; diff --git a/openflare_server/web/features/proxy-routes/types.ts b/openflare_server/web/features/proxy-routes/types.ts index 536ee5dc..46045b70 100644 --- a/openflare_server/web/features/proxy-routes/types.ts +++ b/openflare_server/web/features/proxy-routes/types.ts @@ -53,11 +53,12 @@ export interface ProxyRouteItem { basic_auth_username: string; basic_auth_password: string; remark: string; - upstream_type: 'direct' | 'tunnel'; + upstream_type: 'direct' | 'tunnel' | 'pages'; tunnel_node_id?: number | null; tunnel_id?: number | null; tunnel_target_addr?: string; tunnel_target_protocol?: string; + pages_project_id?: number | null; created_at: string; updated_at: string; } @@ -93,11 +94,12 @@ export interface ProxyRouteMutationPayload { basic_auth_username?: string; basic_auth_password?: string; remark: string; - upstream_type?: 'direct' | 'tunnel'; + upstream_type?: 'direct' | 'tunnel' | 'pages'; tunnel_node_id?: number | null; tunnel_id?: number | null; tunnel_target_addr?: string; tunnel_target_protocol?: string; + pages_project_id?: number | null; } export interface TlsCertificateItem { diff --git a/openflare_server/web/lib/constants/navigation.ts b/openflare_server/web/lib/constants/navigation.ts index 922fea20..17ea9444 100644 --- a/openflare_server/web/lib/constants/navigation.ts +++ b/openflare_server/web/lib/constants/navigation.ts @@ -16,6 +16,11 @@ export const dashboardNavigation: NavigationItem[] = [ label: '规则', icon: 'proxy', }, + { + href: '/pages', + label: 'Pages', + icon: 'pages', + }, { href: '/website', label: '网站', diff --git a/openflare_server/web/tests/unit/nodes-utils.test.ts b/openflare_server/web/tests/unit/nodes-utils.test.ts index fadf4203..a7b19335 100644 --- a/openflare_server/web/tests/unit/nodes-utils.test.ts +++ b/openflare_server/web/tests/unit/nodes-utils.test.ts @@ -18,6 +18,7 @@ function buildNode(overrides: Partial = {}): NodeItem { relay_client_proxy_url: '', relay_auth_token: '', relay_status: 'healthy', + relay_web_server_enabled: false, relay_frps_connections: 0, relay_frps_proxy_count: 0, geo_name: '', diff --git a/openflare_server/web/types/navigation.ts b/openflare_server/web/types/navigation.ts index 53c9e167..b63606c8 100644 --- a/openflare_server/web/types/navigation.ts +++ b/openflare_server/web/types/navigation.ts @@ -5,6 +5,7 @@ export type NavigationIconKey = | 'origin' | 'domain' | 'certificate' + | 'pages' | 'proxy' | 'waf' | 'release'