Compare commits

...

19 Commits

Author SHA1 Message Date
ryan 8ad4defcc7 [功能] POW 有效期优化 2026-04-25 20:49:08 +08:00
ryan d3d32a6b6b [fix] anubis 2026-04-25 19:48:30 +08:00
ryan 9c57ec2f5c [功能] POW 集成 2026-04-19 23:00:57 +08:00
ryan f8c1fe804d [修复] 修复github登录问题 2026-04-01 10:44:34 +08:00
ryan 89489c8488 [功能] 添加卸载脚本以支持彻底卸载 OpenFlare Agent 并清空本地数据 2026-04-01 10:24:05 +08:00
ryan d425e34f71 [优化] 更新默认服务器块,添加 HTTPS 支持并启用 SSL 握手拒绝 2026-04-01 10:02:40 +08:00
ryan 49472b54bf [功能] 添加域名证书绑定支持,允许为每个域名单独选择证书并优化相关逻辑 2026-04-01 09:57:40 +08:00
ryan a002d98f3a [优化] 更新域名列表输入组件,优化按钮样式并支持自定义容器类型 2026-04-01 09:34:17 +08:00
ryan 77457250cf [功能] 更新域名列表输入组件,支持为每个域名选择证书并优化相关逻辑 2026-04-01 09:27:41 +08:00
ryan cff815bd47 [功能] 支持为 HTTPS 启用多个证书,更新相关逻辑和测试 2026-03-31 14:16:32 +08:00
ryan 97fa56b1af [功能] 添加域名列表输入组件,支持动态建议和批量输入 2026-03-31 13:29:30 +08:00
ryan 355791f2e4 [优化] 文本优化 2026-03-31 13:16:00 +08:00
ryan 65ecc27907 [优化] 文本优化 2026-03-31 13:13:38 +08:00
ryan c2184affed [功能] 添加批量更新选项接口,支持一次性更新多个配置项,更新相关逻辑和测试 2026-03-30 16:48:10 +08:00
ryan 7d9190a8d8 [功能] 禁用新用户注册功能,更新相关逻辑和测试 2026-03-30 16:01:25 +08:00
ryan 4b1e75f86b [修改] 文本优化 2026-03-30 15:46:54 +08:00
ryan 25fe178cb2 [功能] 添加网站创建抽屉组件,支持域名和上游地址输入,更新相关逻辑和测试 2026-03-30 15:07:59 +08:00
ryan 383a039338 [功能] 接口与校验改造 2026-03-30 14:45:28 +08:00
ryan e39a8995f6 [功能] 添加站点名称和多域名支持到代理路由,更新相关逻辑和测试 2026-03-30 14:11:30 +08:00
68 changed files with 8408 additions and 3106 deletions
+2
View File
@@ -48,3 +48,5 @@ go.work.sum
.DS_Store
.codex-cache
/.gomodcache/
/anubis-1.25.0/
*.mmdb
+17
View File
@@ -24,6 +24,12 @@
</a>
</p>
> [!NOTE]
> 当前项目处于快速迭代期,设计与实现均不稳定,请确保使用最新版本并关注更新日志。
> [!WARNING]
> 使用 root 用户初次登录系统后,务必修改默认密码 `123456`,并且确保关闭新用户注册功能。
## 为什么存在
OpenFlare 解决的是一类朴素但高频的运维问题:
@@ -150,6 +156,17 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
安装脚本默认写入 `/opt/openflare-agent`,创建 `openflare-agent.service`,并可重复执行以重装或升级 Agent。
如需彻底卸载 Agent 并清空本地数据,可执行:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
卸载脚本会先停止并移除 `openflare-agent.service`、删除整个 `/opt/openflare-agent` 目录,然后根据卸载前保存的 `agent.json` 判断 OpenResty 安装方式:
* Docker 模式:删除对应 OpenResty 容器,并尝试移除镜像
* 本机 `openresty_path` 模式:不改动本机 OpenResty,只提示用户手动卸载
### 3. 发布第一份配置
1. 登录管理端并新增反代规则
+2
View File
@@ -57,6 +57,8 @@ go run . --port 3000 --log-dir ./logs
| `NodeOfflineThreshold` | 节点离线阈值(毫秒) | `120000` |
| `AgentUpdateRepo` | Agent 自更新仓库 | `Rain-kl/OpenFlare` |
| `GeoIPProvider` | 节点/IP 归属解析方式 | `ipinfo` |
| `RegisterEnabled` | 是否允许新用户注册 | `false` |
| `PasswordRegisterEnabled` | 是否允许通过密码方式注册 | `true` |
| `DatabaseAutoCleanupEnabled` | 是否启用每日自动清理观测数据 | `false` |
| `DatabaseAutoCleanupRetentionDays` | 自动清理保留天数(至少 1 天) | `30` |
| `GlobalApiRateLimitNum` / `GlobalApiRateLimitDuration` | 全局 API 限流次数 / 时间窗口 | `300` / `180` |
+16
View File
@@ -237,6 +237,22 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
安装脚本会下载最新 Agent、生成 `agent.json`、创建 `openflare-agent.service` 并启动服务。
如需彻底卸载 Agent 并清空本地数据,可执行:
```bash
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
```
支持参数:
* `--install-dir`
* `--service-name`
卸载脚本会先停止 Agent、移除 `openflare-agent.service`、删除整个安装目录,再根据卸载前保存的 `agent.json` 判断 OpenResty 安装方式:
* Docker 模式:删除对应容器,并尝试移除 OpenResty 镜像
* 本机 `openresty_path` 模式:不改动本机 OpenResty,仅提示用户手动卸载
## 9. 文档维护要求
部署方式、升级方式、接入模式或联调流程变化时,同步更新本文档和 `README.md`。
+3 -1
View File
@@ -109,7 +109,9 @@ Origin
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
* 网站级流量限制、反向代理、HTTPS 与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置
* 网站级流量限制、反向代理与缓存配置当前按站点共享,不在同一网站内做域名级差异化配置;但 HTTPS 允许在同一站点内按域名绑定证书
* `proxy_routes.domain_cert_ids` 用于记录与 `domains` 平行的域名证书绑定;值为 `0` 表示该域名不启用 HTTPS,仅保留 HTTP
* 发布渲染时,带证书的域名按证书分组输出独立 `443 ssl` `server` 块;未绑定证书的域名不得被自动带入 HTTPS
* 发布渲染时必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散
* 所有上游地址都必须为合法 `http://` 或 `https://`
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`
+3 -1
View File
@@ -124,7 +124,9 @@
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
* 流量限制、反向代理、HTTPS 与缓存配置当前都归属站点级 `proxy_routes`,同一网站内不拆分域名级差异配置
* 流量限制、反向代理与缓存配置当前都归属站点级 `proxy_routes`,同一网站内不拆分域名级差异配置
* HTTPS 的启停仍由站点级 `proxy_routes` 控制,但证书绑定必须通过与 `domains` 平行的 `domain_cert_ids` 记录逐域名保存;未绑定证书的域名不得参与 HTTPS 渲染
* `proxy_routes.cert_ids` 仅作为站点级证书集合与兼容镜像,必须由 `domain_cert_ids` 推导生成;`cert_id` 继续作为首个已使用证书的兼容镜像
* `config_versions` 必须保存完整快照与渲染结果
* 全局同时只能有一个激活版本
* 回滚通过重新激活旧版本实现
+12 -19
View File
@@ -6,7 +6,7 @@
现阶段已经出现以下真实需求:
* 多个域名指向同一站点,并共享反向代理、HTTPS、缓存等设置
* 多个域名指向同一站点,并共享反向代理、缓存等设置,同时允许按域名分别绑定 HTTPS 证书
* 后续希望围绕“网站”继续叠加更多功能,而不是持续在规则列表中堆积字段
* 现有抽屉式编辑界面已经不适合承载更复杂的配置结构
@@ -29,7 +29,6 @@
* 域名设置
* 流量限制
* 反向代理
* HTTPS
* 缓存
## 4. 核心模型要求
@@ -89,8 +88,7 @@
1. 域名设置
2. 流量限制
3. 反向代理
4. HTTPS
5. 缓存
4. 缓存
为降低跨分区校验干扰,每个分区应支持独立保存与反馈;若采用统一保存,也必须提供未保存修改提示。
@@ -102,12 +100,16 @@
* 可维护 `domains` 列表
* 可新增、删除、排序域名
* 明确提示第一项为主域名
* 每个域名可单独选择一张证书,形成与 `domains` 平行的 `domain_cert_ids`
* 若某个域名未选择证书,则该域名不启用 HTTPS
* `HTTP -> HTTPS` 跳转逻辑与域名证书绑定放在同一分区维护
* 保存前校验:
`site_name` 非空且唯一
`domains` 非空
每个域名格式合法
域名在当前站点内不重复
域名在全局不与其他网站冲突
已选择证书的域名必须被对应证书覆盖
### 5.4 流量限制
@@ -141,17 +143,7 @@
* 多上游模式下,上游项保持 `scheme://host[:port]` 形式
* 同一网站的多个上游在多上游模式下维持统一协议,降低渲染复杂度
### 5.6 HTTPS
HTTPS 分区负责维护站点级 TLS 行为,要求如下:
* 支持开启或关闭 HTTPS
* 支持选择证书
* 支持保留现有 `HTTP -> HTTPS` 跳转能力
* 当 HTTPS 开启时必须明确证书来源
* 应校验证书是否覆盖当前网站的全部域名;若无法覆盖,应阻止保存或给出不可忽略的错误提示
### 5.7 缓存
### 5.6 缓存
缓存分区负责维护站点级缓存策略,要求如下:
@@ -175,6 +167,7 @@ HTTPS 分区负责维护站点级 TLS 行为,要求如下:
域名列表变更
站点级配置变更
* 发布渲染时,同一网站的全部域名必须落入同一份站点配置上下文中
* 同一网站内,带证书的域名需按证书分组生成 HTTPS `server`;未配置证书的域名只保留 HTTP
## 7. 前端实现要求
@@ -189,8 +182,8 @@ HTTPS 分区负责维护站点级 TLS 行为,要求如下:
实施前必须准备显式数据库迁移与校验逻辑,至少包含:
1. 新增 `site_name` 与 `domains` 存储结构
2. 将旧数据从单域名回填到站点结构
1. 新增 `site_name`、`domains` 与 `domain_cert_ids` 存储结构
2. 将旧数据从单域名回填到站点结构,并补齐逐域名证书映射
3. 为 `site_name` 建立唯一约束
4. 为域名唯一性建立可校验约束
5. 对迁移结果做一致性校验
@@ -241,7 +234,7 @@ HTTPS 分区负责维护站点级 TLS 行为,要求如下:
* 列表页 UI 改造
* 子页面路由与布局
* 域名设置、流量限制、反向代理、HTTPS、缓存五个分区
* 域名设置、流量限制、反向代理、缓存四个分区
* 前端交互与表单测试
### 阶段四:联调、发布验证与文档收口
@@ -268,6 +261,6 @@ HTTPS 分区负责维护站点级 TLS 行为,要求如下:
* 原列表页已用“配置”按钮替代“编辑”按钮
* 网站配置子页面已经采用左侧菜单、右侧设置的布局
* 五个分区均可独立完成基本配置与保存
* 发布后的渲染结果可正确覆盖同一网站的全部域名
* 发布后的渲染结果可正确覆盖同一网站的全部域名,并只为已绑定证书的域名生成 HTTPS 配置
* Agent 同步、应用、回滚链路不被破坏
* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证
+1 -1
View File
@@ -1,6 +1,6 @@
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "2380de64b00e99093e16590beb91e1a0",
"agent_token": "373956188ddead1df6dd7c86cd330b73",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
+110 -9
View File
@@ -27,6 +27,7 @@ const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
@@ -396,6 +397,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
if err := m.writeCertFiles(supportFiles); err != nil {
return err
}
if err := m.writePowConfig(supportFiles); err != nil {
return err
}
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
}
@@ -450,8 +454,21 @@ func (m *Manager) EnsureLuaAssets() error {
if strings.TrimSpace(m.LuaDir) == "" {
return nil
}
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
for _, file := range ManagedObservabilityLuaFiles() {
allSupportFiles := append(ManagedObservabilityLuaFiles(), ManagedPowLuaFiles()...)
existingPowConfig, err := m.readPowConfigFile()
if err != nil {
return err
}
if existingPowConfig != nil {
allSupportFiles = append(allSupportFiles, *existingPowConfig)
}
powStaticFiles, err := ManagedPowStaticFiles()
if err != nil {
return fmt.Errorf("load pow static files: %w", err)
}
allSupportFiles = append(allSupportFiles, powStaticFiles...)
files := make([]managedFile, 0, len(allSupportFiles))
for _, file := range allSupportFiles {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
if err != nil {
return err
@@ -536,7 +553,11 @@ func (m *Manager) CurrentChecksum() (string, error) {
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
}
files, err := m.readCertFiles()
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
}
files, err := m.readManagedSupportFiles()
if err != nil {
return "", err
}
@@ -693,6 +714,7 @@ type backupState struct {
RouteExisted bool
RouteData []byte
Files []protocol.SupportFile
PowConfig *protocol.SupportFile
}
type managedFile struct {
@@ -739,6 +761,11 @@ func (m *Manager) backup() (*backupState, error) {
return nil, err
}
state.Files = files
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
state.PowConfig = powConfig
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
return state, nil
}
@@ -762,10 +789,12 @@ func (m *Manager) restore(state *backupState) error {
} else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
return err
}
if m.CertDir == "" {
return nil
if m.CertDir != "" {
if err := m.writeManagedCertFiles(state.Files); err != nil {
return err
}
}
return m.writeManagedCertFiles(state.Files)
return m.restorePowConfig(state)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
@@ -775,6 +804,26 @@ func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
return m.writeManagedCertFiles(certFiles)
}
func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error {
if m.LuaDir == "" {
return nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
for _, file := range supportFiles {
if file.Path == "pow_config.json" {
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
return fmt.Errorf("write pow_config.json: %w", err)
}
slog.Info("wrote pow config", "path", configPath, "size", len(file.Content))
return nil
}
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove pow_config.json: %w", err)
}
return nil
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
@@ -836,6 +885,53 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
return files, nil
}
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
if m.LuaDir == "" {
return nil, nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &protocol.SupportFile{
Path: "pow_config.json",
Content: string(data),
}, nil
}
func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
files, err := m.readCertFiles()
if err != nil {
return nil, err
}
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
if powConfig != nil {
files = append(files, *powConfig)
}
return files, nil
}
func (m *Manager) restorePowConfig(state *backupState) error {
if state == nil || m.LuaDir == "" {
return nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
if state.PowConfig == nil {
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
}
func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
if strings.TrimSpace(m.CertDir) == "" {
return "", errors.New("cert dir 不能为空")
@@ -988,10 +1084,15 @@ func removeEmptyManagedDirs(baseDir string) error {
}
func (m *Manager) renderRouteConfig(content string) string {
if m.NginxCertDir == "" {
return content
rendered := content
if m.NginxCertDir != "" {
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
}
return strings.ReplaceAll(content, CertDirPlaceholder, m.NginxCertDir)
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
}
return rendered
}
func (m *Manager) renderMainConfig(content string) string {
@@ -736,6 +736,13 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
}
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n")
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
}
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
}
mainData, err := os.ReadFile(manager.MainConfigPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
@@ -867,6 +874,12 @@ func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "check.lua")); err != nil {
t.Fatalf("expected managed pow lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "static", "js", "main.mjs")); err != nil {
t.Fatalf("expected managed pow static asset to exist, stat err = %v", err)
}
}
func TestCertFileMode(t *testing.T) {
@@ -906,6 +919,105 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
if luaInfo.Mode().Perm() != 0o644 {
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
}
if _, err := os.Stat(filepath.Join(manager.LuaDir, "pow", "check.lua")); err != nil {
t.Fatalf("failed to stat pow lua file: %v", err)
}
}
func TestEnsureLuaAssetsPreservesPowConfig(t *testing.T) {
tempDir := t.TempDir()
luaDir := filepath.Join(tempDir, "lua")
if err := os.MkdirAll(luaDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
powConfigPath := filepath.Join(luaDir, "pow_config.json")
want := `[{"domains":["pow.example.com"],"enabled":true}]`
if err := os.WriteFile(powConfigPath, []byte(want), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{LuaDir: luaDir}
if err := manager.EnsureLuaAssets(); err != nil {
t.Fatalf("EnsureLuaAssets failed: %v", err)
}
got, err := os.ReadFile(powConfigPath)
if err != nil {
t.Fatalf("expected pow_config.json to remain after EnsureLuaAssets: %v", err)
}
if string(got) != want {
t.Fatalf("unexpected pow_config.json content: got %s want %s", string(got), want)
}
}
func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) {
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "routes.conf")
luaDir := filepath.Join(tempDir, "lua")
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{},
}
outcome := manager.Apply(
context.Background(),
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
value, err := manager.CurrentChecksum()
if err != nil {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if value != expected {
t.Fatalf("unexpected checksum with pow config: got %s want %s", value, expected)
}
}
func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
if !strings.Contains(openRestyPowCheckLua, `return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) {
t.Fatal("expected check.lua to internally execute make-challenge instead of issuing a 302 redirect")
}
if strings.Contains(openRestyPowCheckLua, "ngx.redirect(") {
t.Fatal("expected check.lua to avoid external redirects for challenge rendering")
}
if !strings.Contains(openRestyPowChallengeLua, `<h1 id="title" class="centered-div">`) {
t.Fatal("expected challenge html to include Anubis-compatible title node")
}
if !strings.Contains(openRestyPowChallengeLua, `<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>`) {
t.Fatal("expected challenge html to include Anubis-compatible progress markup")
}
if !strings.Contains(openRestyPowChallengeLua, `<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>`) {
t.Fatal("expected challenge html to force Anubis frontend to reuse the current URL as redir target")
}
if !strings.Contains(openRestyPowCheckLua, `pow_sessions:set(session_key, "1", session_ttl)`) {
t.Fatal("expected check.lua to refresh the PoW session TTL on each valid request")
}
if !strings.Contains(openRestyPowCheckLua, `ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)`) {
t.Fatal("expected check.lua to refresh the browser session cookie on each valid request")
}
if !strings.Contains(openRestyPowChallengeLua, `local session_ttl = config.session_ttl or 600`) {
t.Fatal("expected challenge.lua to default session TTL to 10 minutes")
}
if !strings.Contains(openRestyPowVerifyLua, `local session_ttl = challenge_info.session_ttl or 600`) {
t.Fatal("expected verify.lua to default session TTL to 10 minutes")
}
if !strings.Contains(openRestyPowVerifyLua, `if ngx.var.scheme == "https" then`) {
t.Fatal("expected verify.lua to only mark the session cookie as Secure for HTTPS requests")
}
}
func TestManagerRollbackRestoresCertFiles(t *testing.T) {
@@ -0,0 +1,535 @@
package nginx
import (
"embed"
"openflare-agent/internal/protocol"
"path/filepath"
"strings"
)
//go:embed pow_static
var powStaticFS embed.FS
const openRestyPowCheckLua = `local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local cjson = require "cjson.safe"
local policy = require "pow.policy"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_sessions = ngx.shared.openflare_pow_sessions
local function session_cookie(value, ttl)
local cookie = "__openflare_pow=" .. value .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
return cookie
end
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
ngx.config.prefix() .. "openflare-lua/pow_config.json",
"/etc/nginx/openflare-lua/pow_config.json",
"/usr/local/openresty/nginx/conf/pow_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
if f then
local content = f:read("*a")
f:close()
local current_hash = ngx.md5(content or "")
if current_hash == pow_config_dict:get("_config_hash") then
return
end
-- Clear old domain entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
pow_config_dict:delete(domain)
end
end
local domain_keys = {}
if content and content ~= "" and content ~= "{}" then
local ok, entries = pcall(cjson.decode, content)
if ok and entries and type(entries) == "table" then
for _, entry in ipairs(entries) do
if entry.domains then
for _, domain in ipairs(entry.domains) do
pow_config_dict:set(domain, cjson.encode(entry), 0)
domain_keys[#domain_keys+1] = domain
end
end
end
end
end
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
pow_config_dict:set("_config_hash", current_hash, 0)
return
end
end
end
load_pow_config()
local host = ngx.var.host
if not host or host == "" then
return
end
local config_raw = pow_config_dict:get(host)
if not config_raw then
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config then
return
end
if not route_config.enabled then
return
end
local config = route_config.config or {}
local session_ttl = config.session_ttl or 600
local uri = ngx.var.uri or ""
local ua = ngx.var.http_user_agent or ""
local remote_ip = ngx.var.remote_addr or ""
-- Check whitelist: if matched, skip PoW
local whitelist = config.whitelist or {}
if policy.match_any(remote_ip, ua, uri, whitelist) then
return
end
-- Check blacklist: if matched, require PoW
local blacklist = config.blacklist or {}
local has_blacklist = policy.has_entries(blacklist)
local need_pow = false
if has_blacklist then
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
else
-- No blacklist means all non-whitelisted need PoW
need_pow = true
end
if not need_pow then
return
end
-- Check valid session cookie
local cookie_val = ngx.var["cookie___openflare_pow"]
if cookie_val and cookie_val ~= "" then
local session_key = host .. ":" .. cookie_val
local session_data = pow_sessions:get(session_key)
if session_data then
pow_sessions:set(session_key, "1", session_ttl)
ngx.header["Set-Cookie"] = session_cookie(cookie_val, session_ttl)
return
end
end
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
return
end
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
return
end
-- Render the challenge page through an internal redirect so the browser stays
-- on the originally requested URL instead of seeing a 302 hop.
ngx.req.set_uri_args({
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
host = host
})
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
`
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_challenges = ngx.shared.openflare_pow_challenges
local function generate_entropy()
local pieces = {
tostring(ngx.now()),
tostring(ngx.worker.pid()),
tostring(math.random()),
ngx.var.remote_addr or "",
ngx.var.http_user_agent or "",
ngx.var.request_id or "",
}
return table.concat(pieces, ":")
end
local args = ngx.req.get_uri_args()
local host = args["host"] or ngx.var.host or ""
local redir = args["redir"] or ""
local config_raw = pow_config_dict:get(host)
if not config_raw then
ngx.status = 403
ngx.say("PoW not configured for this host")
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config or not route_config.enabled then
ngx.status = 403
ngx.say("PoW not enabled for this host")
return
end
local config = route_config.config or {}
local difficulty = config.difficulty or 4
local algorithm = config.algorithm or "fast"
local challenge_ttl = config.challenge_ttl or 300
local session_ttl = config.session_ttl or 600
-- Generate challenge data without depending on ngx.random_bytes, which is not
-- available in every OpenResty runtime build.
local entropy = generate_entropy()
local challenge_id = ngx.md5(entropy .. ":id")
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
-- Store challenge
local challenge_info = cjson.encode({
data = challenge_data,
difficulty = difficulty,
host = host,
redir = redir,
session_ttl = session_ttl
})
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
local static_prefix = "/.within.website/x/cmd/anubis/static/"
local title = "Making sure you're not a bot!"
local lang = "en"
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="]] .. lang .. [[">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>]] .. title .. [[</title>
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
<style>
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
.centered-div{text-align:center}
#status{font-variant-numeric:tabular-nums}
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
</style>
<script id="anubis_version" type="application/json">"openflare-pow"</script>
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
challenge = {
id = challenge_id,
randomData = challenge_data,
method = algorithm
},
rules = {
difficulty = difficulty,
algorithm = algorithm
}
}) .. [[</script>
<script id="anubis_base_prefix" type="application/json">""</script>
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
</head>
<body id="top">
<main>
<h1 id="title" class="centered-div">]] .. title .. [[</h1>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
<p id="status">Loading...</p>
<p>This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.</p>
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
<details>
<summary>Why am I seeing this?</summary>
<p>OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.</p>
</details>
<noscript><p>JavaScript is required to pass this verification. Please enable JavaScript and reload.</p></noscript>
</div>
</main>
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
</body>
</html>]])
`
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_sessions = ngx.shared.openflare_pow_sessions
local args = ngx.req.get_uri_args()
local challenge_id = args["id"] or ""
local response = args["response"] or ""
local nonce_str = args["nonce"] or ""
local redir = args["redir"] or ""
local elapsed = args["elapsedTime"] or ""
if challenge_id == "" or response == "" or nonce_str == "" then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "missing parameters"}))
return
end
local nonce = tonumber(nonce_str)
if not nonce then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid nonce"}))
return
end
-- Get stored challenge
local challenge_raw = pow_challenges:get(challenge_id)
if not challenge_raw then
ngx.status = 410
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "challenge expired or not found"}))
return
end
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
if not ok or not challenge_info then
ngx.status = 500
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid challenge data"}))
return
end
local challenge_data = challenge_info.data or ""
local difficulty = challenge_info.difficulty or 4
local host = challenge_info.host or ngx.var.host or ""
local session_ttl = challenge_info.session_ttl or 600
-- Compute SHA-256(challenge_data + nonce)
local calc_string = challenge_data .. tostring(math.floor(nonce))
local calculated = ngx.sha1_bin ~= nil and "" or ""
-- Use resty.sha256 for proper SHA-256
local sha256 = require "resty.sha256"
local str = require "resty.string"
local hasher = sha256:new()
hasher:update(calc_string)
local hash_bytes = hasher:final()
local hash_hex = str.to_hex(hash_bytes)
-- Verify hash matches response
if hash_hex ~= string.lower(response) then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "hash mismatch"}))
return
end
-- Verify difficulty (leading zeros in hex)
local prefix = string.rep("0", difficulty)
if string.sub(hash_hex, 1, difficulty) ~= prefix then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "insufficient difficulty"}))
return
end
-- Invalidate challenge (prevent replay)
pow_challenges:delete(challenge_id)
-- Generate session token
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
-- Store session
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
-- the current request itself is HTTPS.
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
ngx.header["Set-Cookie"] = cookie
if redir ~= "" then
return ngx.redirect(redir)
end
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({ok = true}))
`
const openRestyPowPolicyLua = `local M = {}
local function match_ip(remote_ip, ips)
if not ips or #ips == 0 then return false end
for _, ip in ipairs(ips) do
if ip == remote_ip then
return true
end
end
return false
end
local function match_cidr(remote_ip, cidrs)
if not cidrs or #cidrs == 0 then return false end
for _, cidr in ipairs(cidrs) do
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
if m then
local mask_bits = tonumber(m[2])
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
local function ip_to_num(ip_str)
local parts = {}
for part in string.gmatch(ip_str, "%d+") do
parts[#parts+1] = tonumber(part) or 0
end
if #parts ~= 4 then return 0 end
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
end
local remote_num = ip_to_num(remote_ip)
local net_num = ip_to_num(m[1])
if mask_bits == 0 then
return true
end
local mask = math.floor(2^(32 - mask_bits))
mask = 4294967296 - mask
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
return true
end
end
end
end
return false
end
local function match_path(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
if c == "*" then return ".*" end
return "%" .. c
end) .. "$", "i")
if ok and match then
return true
end
end
return false
end
local function match_path_regex(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, pattern)
if ok and match then
return true
end
end
return false
end
local function match_ua(ua, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
if ua and string.find(ua, pattern, 1, true) then
return true
end
end
return false
end
function M.match_any(remote_ip, ua, uri, list)
if not list then return false end
if match_ip(remote_ip, list.ips) then return true end
if match_cidr(remote_ip, list.ip_cidrs) then return true end
if match_path(uri, list.paths) then return true end
if match_path_regex(uri, list.path_regexes) then return true end
if match_ua(ua, list.user_agents) then return true end
return false
end
function M.has_entries(list)
if not list then return false end
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
end
return M
`
func ManagedPowLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
}
}
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
var files []protocol.SupportFile
entries, err := powStaticFS.ReadDir("pow_static")
if err != nil {
return nil, err
}
var walk func(dir string) error
walk = func(dir string) error {
entries, err := powStaticFS.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
fullPath := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return err
}
continue
}
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return err
}
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
return nil
}
for _, entry := range entries {
fullPath := filepath.Join("pow_static", entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return nil, err
}
} else {
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return nil, err
}
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
}
return files, nil
}
@@ -0,0 +1,7 @@
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("podkova.woff2") format("woff2");
}
@@ -0,0 +1,149 @@
:root {
--body-sans-font: Geist, sans-serif;
--body-preformatted-font: Iosevka Curly Iaso, monospace;
--body-title-font: Podkova, serif;
--background: #1d2021;
--text: #f9f5d7;
--text-selection: #d3869b;
--preformatted-background: #3c3836;
--link-foreground: #b16286;
--link-background: #282828;
--blockquote-border-left: 1px solid #bdae93;
--progress-bar-outline: #b16286 solid 4px;
--progress-bar-fill: #b16286;
}
@media (prefers-color-scheme: light) {
:root {
--background: #f9f5d7;
--text: #1d2021;
--text-selection: #d3869b;
--preformatted-background: #ebdbb2;
--link-foreground: #b16286;
--link-background: #fbf1c7;
--blockquote-border-left: 1px solid #655c54;
}
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("./static/geist.woff2") format("woff2");
}
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("./static/podkova.woff2") format("woff2");
}
@font-face {
font-family: "Iosevka Curly";
font-style: monospace;
font-display: swap;
src: url("./static/iosevka-curly.woff2") format("woff2");
}
main {
font-family: var(--body-sans-font);
max-width: 50rem;
padding: 2rem;
margin: auto;
}
::selection {
background: var(--text-selection);
}
body {
background: var(--background);
color: var(--text);
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
#progress {
display: none;
width: min(20rem, 90%);
height: 2rem;
border-radius: 1rem;
overflow: hidden;
margin: 1rem 0 2rem;
outline-offset: 2px;
outline: var(--progress-bar-outline);
}
.bar-inner {
background-color: var(--progress-bar-fill);
height: 100%;
width: 0;
transition: width 0.25s ease-in;
}
@media (prefers-reduced-motion: no-preference) {
.bar-inner {
transition: width 0.25s ease-in;
}
}
pre {
background-color: var(--preformatted-background);
padding: 1em;
border: 0;
font-family: var(--body-preformatted-font);
}
a,
a:active,
a:visited {
color: var(--link-foreground);
background-color: var(--link-background);
}
h1,
h2,
h3,
h4,
h5 {
margin-bottom: 0.1rem;
font-family: var(--body-title-font);
}
blockquote {
border-left: var(--blockquote-border-left);
margin: 0.5em 10px;
padding: 0.5em 10px;
}
footer {
text-align: center;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r<u;r++){let t=new Worker(m);t.onmessage=o=>{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})();
//# sourceMappingURL=index.mjs.map
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c<u;c++){let g=new Worker(M);g.onmessage=m=>{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null)return e&&window.location.href.startsWith(e)?new URLSearchParams(window.location.search).get("redir"):window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.innerHTML=l,n.innerHTML=f,s.src=w,i.style.display="none"};n.innerHTML=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.innerHTML=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.innerHTML=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})();
//# sourceMappingURL=main.mjs.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var h=new TextEncoder,y=async e=>{let s=h.encode(e);return await crypto.subtle.digest("SHA-256",s)},g=e=>e.reduce((s,a)=>s+a.toString(16).padStart(2,"0"),"");addEventListener("message",async({data:e})=>{let{data:s,difficulty:a,threads:d}=e,t=e.nonce,f=t===0,o=0,c=Math.floor(a/2),l=a%2!==0;for(;;){let u=await y(s+t),i=new Uint8Array(u),r=!0;for(let n=0;n<c;n++)if(i[n]!==0){r=!1;break}if(r&&l&&i[c]>>4!==0&&(r=!1),r){let n=g(i);postMessage({hash:n,data:s,difficulty:a,nonce:t});return}t+=d,o++,t%1!==0&&(t=Math.trunc(t)),f&&(o&1023)===0&&postMessage(t)}});})();
//# sourceMappingURL=sha256-webcrypto.mjs.map
@@ -0,0 +1,66 @@
{
"loading": "Loading...",
"why_am_i_seeing": "Why am I seeing this?",
"protected_by": "Protected by",
"protected_from": "From",
"made_with": "Made with ❤️ in 🇨🇦",
"mascot_design": "Mascot design by",
"ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.",
"anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.",
"hack_purpose": "Ultimately, this is a placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.",
"simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">Proof of Work</a>. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.",
"jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.",
"version_info": "This website is running Anubis version",
"try_again": "Try again",
"go_home": "Go home",
"contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at",
"connection_security": "Please wait a moment while we ensure the security of your connection.",
"javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.",
"benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.",
"difficulty": "Difficulty:",
"algorithm": "Algorithm:",
"compare": "Compare:",
"time": "Time",
"iters": "Iters",
"time_a": "Time A",
"iters_a": "Iters A",
"time_b": "Time B",
"iters_b": "Iters B",
"static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.",
"authorization_required": "Authorization required",
"cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain",
"access_denied": "Access Denied: error code",
"dronebl_entry": "DroneBL reported an entry",
"see_dronebl_lookup": "see",
"internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around",
"invalid_redirect": "Invalid redirect",
"redirect_not_parseable": "Redirect URL not parseable",
"redirect_domain_not_allowed": "Redirect domain not allowed",
"missing_required_forwarded_headers": "Missing required X-Forwarded-* headers",
"failed_to_sign_jwt": "failed to sign JWT",
"invalid_invocation": "Invalid invocation of MakeChallenge",
"client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.",
"oh_noes": "Oh noes!",
"benchmarking_anubis": "Benchmarking Anubis!",
"you_are_not_a_bot": "You are not a bot!",
"making_sure_not_bot": "Making sure you're not a bot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?",
"js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?",
"js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.",
"js_context_not_secure": "Your context is not secure!",
"js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calculating...",
"js_missing_feature": "Missing feature",
"js_challenge_error": "Challenge error!",
"js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.",
"js_calculating_difficulty": "Calculating...<br/>Difficulty:",
"js_speed": "Speed:",
"js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.",
"js_success": "Success!",
"js_done_took": "Done! Took",
"js_iterations": "iterations",
"js_finished_reading": "I've finished reading, continue →",
"js_calculation_error": "Calculation error!",
"js_calculation_error_msg": "Failed to calculate challenge:"
}
@@ -0,0 +1,66 @@
{
"loading": "加载中...",
"why_am_i_seeing": "为什么我会看到这个?",
"protected_by": "本网站由",
"protected_from": "保护,来自",
"made_with": "在 🇨🇦 用 ❤️ 制作",
"mascot_design": "吉祥物由",
"ai_companies_explanation": "您会看到这个画面,是因为网站管理员启用了 Anubis 来保护服务器,避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃,让所有用户都无法正常访问资源。",
"anubis_compromise": "Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制(Proof-of-Work),该机制最初是为了减少垃圾邮件而提出。其核心概念是:对个别用户而言,额外的计算负担可以忽略,但对大规模爬虫来说,累积起来的成本将大幅增加,从而让爬取行为变得更困难。",
"hack_purpose": "最终,这是一个占位符解决方案,以便将更多时间用于指纹识别和识别无头浏览器(例如:通过它们如何进行字体渲染),从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。",
"jshelter_note": "请注意,Anubis 需要使用现代 JavaScript 功能,而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。",
"version_info": "这个网站正在运行的 Anubis 版本为",
"try_again": "再试一次",
"go_home": "返回首页",
"contact_webmaster": "或者您觉得您不应该被封锁,请联系网站管理员于",
"connection_security": "请稍等,我们需要在继续之前检查您的连接安全性。",
"javascript_required": "很遗憾,您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约,因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。",
"benchmark_requires_js": "运行基准测试工具需要启用 JavaScript。",
"difficulty": "难度:",
"algorithm": "算法:",
"compare": "比较:",
"time": "时间",
"iters": "迭代",
"time_a": "时间 A",
"iters_a": "迭代 A",
"time_b": "时间 B",
"iters_b": "迭代 B",
"static_check_endpoint": "这是提供给您的反向代理服务器使用的检查端点。",
"authorization_required": "需要认证",
"cookies_disabled": "您的浏览器目前已禁用 Cookie,为了确认您是合法用户,Anubis 需要启用 Cookie。 请您为此域名启用 Cookie",
"access_denied": "拒绝访问:错误代码",
"dronebl_entry": "DroneBL 报告了一条记录",
"see_dronebl_lookup": "见",
"internal_server_error": "内部服务器错误:管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志",
"invalid_redirect": "无效的重定向",
"redirect_not_parseable": "重定向 URL 无法解析",
"redirect_domain_not_allowed": "重定向的域名并不允许",
"failed_to_sign_jwt": "签署 JWT 失败",
"invalid_invocation": "无效的 MakeChallenge 调用",
"client_error_browser": "客户端错误:请确保您的浏览器是最新版本并稍候再试。",
"oh_noes": "哎呀糟糕了!",
"benchmarking_anubis": "正在进行 Anubis 性能测试!",
"you_are_not_a_bot": "你不是机器人!",
"making_sure_not_bot": "正在确认你是不是机器人!",
"celphase": "CELPHASE 设计",
"js_web_crypto_error": "您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接(HTTPS)查看此网站?",
"js_web_workers_error": "您的浏览器并不支持 Web workers (Anubis 使用这个来避免冻结您的浏览器 )您有安装像是 JShelter 之类的插件吗?",
"js_cookies_error": "您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证,以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意,Anubis 存储的 Cookie 名称可能会变动,且其名称与内容不属于公开 API 的一部分。",
"js_context_not_secure": "您的内容并不安全",
"js_context_not_secure_msg": "请尝试使用 HTTPS 连接,或联系网站管理员设置 HTTPS。更多信息请参见 <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>。",
"js_calculating": "计算中...",
"js_missing_feature": "缺少功能",
"js_challenge_error": "挑战错误!",
"js_challenge_error_msg": "解决检查算法失败。 您可能会想要刷新页面。",
"js_calculating_difficulty": "计算中...<br/>难度:",
"js_speed": "速度:",
"js_verification_longer": "验证所花的时间高于预期。 请不要刷新页面。",
"js_success": "成功!",
"js_done_took": "完成! 花费",
"js_iterations": "迭代",
"js_finished_reading": "我读完了,继续 →",
"js_calculation_error": "计算错误!",
"js_calculation_error_msg": "计算挑战失败:",
"missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头",
"simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为<a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">工作量证明</a>。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。"
}
+1 -1
View File
@@ -31,7 +31,7 @@ var EmailVerificationEnabled = false
var GitHubOAuthEnabled = false
var WeChatAuthEnabled = false
var TurnstileCheckEnabled = false
var RegisterEnabled = true
var RegisterEnabled = false
var SMTPServer = ""
var SMTPPort = 587
+15 -13
View File
@@ -23,19 +23,21 @@ func GetStatus(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"home_page_link": common.HomePageLink,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"version": common.Version,
"start_time": common.StartTime,
"email_verification": common.EmailVerificationEnabled,
"github_oauth": common.GitHubOAuthEnabled,
"github_client_id": common.GitHubClientId,
"system_name": common.SystemName,
"home_page_link": common.HomePageLink,
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": common.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"register_enabled": common.RegisterEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
},
})
return
+100
View File
@@ -22,6 +22,10 @@ var (
openRestyDurationTokenPattern = regexp.MustCompile(`^\d+[smhdwSMHDW]$`)
)
type optionBatchPayload struct {
Options []model.Option `json:"options"`
}
func validateRateLimitOption(key string, value string) error {
maxDurationSeconds := int(common.RateLimitKeyExpirationDuration.Seconds())
@@ -193,6 +197,69 @@ func validateOpenRestyOption(key string, value string) error {
}
}
func buildOptionValidationState(options []model.Option) map[string]string {
common.OptionMapRWMutex.RLock()
state := make(map[string]string, len(common.OptionMap)+len(options))
for key, value := range common.OptionMap {
state[key] = value
}
common.OptionMapRWMutex.RUnlock()
for _, option := range options {
state[option.Key] = option.Value
}
return state
}
func validateOptionWithState(option model.Option, state map[string]string) error {
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["GitHubClientId"]) == "" {
return fmt.Errorf("鏃犳硶鍚敤 GitHub OAuth锛岃鍏堝~鍏?GitHub Client ID 浠ュ強 GitHub Client Secret锛?")
}
case "WeChatAuthEnabled":
if option.Value == "true" && strings.TrimSpace(state["WeChatServerAddress"]) == "" {
return fmt.Errorf("鏃犳硶鍚敤寰俊鐧诲綍锛岃鍏堝~鍏ュ井淇$櫥褰曠浉鍏抽厤缃俊鎭紒")
}
case "TurnstileCheckEnabled":
if option.Value == "true" && strings.TrimSpace(state["TurnstileSiteKey"]) == "" {
return fmt.Errorf("鏃犳硶鍚敤 Turnstile 鏍¢獙锛岃鍏堝~鍏?Turnstile 鏍¢獙鐩稿叧閰嶇疆淇℃伅锛?")
}
}
if err := validateRateLimitOption(option.Key, option.Value); err != nil {
return err
}
if err := validateOpenRestyOption(option.Key, option.Value); err != nil {
return err
}
if err := validateGeoIPOption(option.Key, option.Value); err != nil {
return err
}
if err := validateDatabaseCleanupOption(option.Key, option.Value); err != nil {
return err
}
return nil
}
func updateOptions(options []model.Option) error {
if len(options) == 0 {
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
}
state := buildOptionValidationState(options)
for _, option := range options {
if strings.TrimSpace(option.Key) == "" {
return fmt.Errorf("鏃犳晥鐨勫弬鏁?")
}
if err := validateOptionWithState(option, state); err != nil {
return err
}
}
return model.UpdateOptions(options)
}
// GetOptions godoc
// @Summary List editable options
// @Tags Options
@@ -307,3 +374,36 @@ func UpdateOption(c *gin.Context) {
})
return
}
// UpdateOptionsBatch godoc
// @Summary Batch update options
// @Tags Options
// @Accept json
// @Produce json
// @Param payload body optionBatchPayload true "Batch option payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/option/update-batch [post]
func UpdateOptionsBatch(c *gin.Context) {
var payload optionBatchPayload
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil || len(payload.Options) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "鏃犳晥鐨勫弬鏁?",
})
return
}
if err := updateOptions(payload.Options); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
+39 -5
View File
@@ -2,10 +2,11 @@ package controller
import (
"encoding/json"
"github.com/gin-gonic/gin"
"net/http"
"openflare/service"
"strconv"
"github.com/gin-gonic/gin"
)
// GetProxyRoutes godoc
@@ -31,6 +32,39 @@ func GetProxyRoutes(c *gin.Context) {
})
}
// GetProxyRoute godoc
// @Summary Get proxy route detail
// @Tags ProxyRoutes
// @Produce json
// @Security BearerAuth
// @Param id path int true "Route ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/proxy-routes/{id} [get]
func GetProxyRoute(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid id",
})
return
}
route, err := service.GetProxyRoute(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": route,
})
}
// CreateProxyRoute godoc
// @Summary Create proxy route
// @Tags ProxyRoutes
@@ -46,7 +80,7 @@ func CreateProxyRoute(c *gin.Context) {
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid payload",
})
return
}
@@ -81,7 +115,7 @@ func UpdateProxyRoute(c *gin.Context) {
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid id",
})
return
}
@@ -89,7 +123,7 @@ func UpdateProxyRoute(c *gin.Context) {
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid payload",
})
return
}
@@ -122,7 +156,7 @@ func DeleteProxyRoute(c *gin.Context) {
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
"message": "invalid id",
})
return
}
@@ -4,7 +4,7 @@ import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 4
currentDatabaseSchemaVersion = 9
databaseSchemaVersionRowID = 1
)
+394
View File
@@ -10,6 +10,115 @@ import (
"gorm.io/gorm"
)
type legacyProxyRouteV4 struct {
ID uint `gorm:"primaryKey"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
RedirectHTTP bool `gorm:"not null;default:false"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV4) TableName() string {
return "proxy_routes"
}
type legacyProxyRouteV5 struct {
ID uint `gorm:"primaryKey"`
SiteName string `gorm:"size:255;not null;default:''"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
Domains string `gorm:"type:text;not null;default:'[]'"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
RedirectHTTP bool `gorm:"not null;default:false"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV5) TableName() string {
return "proxy_routes"
}
type legacyProxyRouteV6 struct {
ID uint `gorm:"primaryKey"`
SiteName string `gorm:"size:255;not null;default:''"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
Domains string `gorm:"type:text;not null;default:'[]'"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
RedirectHTTP bool `gorm:"not null;default:false"`
LimitConnPerServer int `gorm:"not null;default:0"`
LimitConnPerIP int `gorm:"not null;default:0"`
LimitRate string `gorm:"size:32;not null;default:''"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV6) TableName() string {
return "proxy_routes"
}
type legacyProxyRouteV7 struct {
ID uint `gorm:"primaryKey"`
SiteName string `gorm:"size:255;not null;default:''"`
Domain string `gorm:"uniqueIndex;size:255;not null"`
Domains string `gorm:"type:text;not null;default:'[]'"`
OriginID *uint `gorm:"index"`
OriginURL string `gorm:"size:2048;not null"`
OriginHost string `gorm:"size:255"`
Upstreams string `gorm:"type:text;not null;default:'[]'"`
Enabled bool `gorm:"not null;default:true"`
EnableHTTPS bool `gorm:"column:enable_https;not null;default:false"`
CertID *uint
CertIDs string `gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `gorm:"not null;default:false"`
LimitConnPerServer int `gorm:"not null;default:0"`
LimitConnPerIP int `gorm:"not null;default:0"`
LimitRate string `gorm:"size:32;not null;default:''"`
CacheEnabled bool `gorm:"not null;default:false"`
CachePolicy string `gorm:"size:32;not null;default:''"`
CacheRules string `gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `gorm:"type:text;not null;default:'[]'"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (legacyProxyRouteV7) TableName() string {
return "proxy_routes"
}
func openBareTestSQLiteDB(t *testing.T, name string) *gorm.DB {
t.Helper()
@@ -464,6 +573,291 @@ func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
}
}
func TestEnsureDatabaseSchemaUpToDateBackfillsProxyRouteSiteFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-sites.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV4{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes: %v", err)
}
now := time.Now().UTC()
if err := db.Create(&legacyProxyRouteV4{
Domain: "app.example.com",
OriginURL: "https://origin-a.internal:8443",
Upstreams: `["https://origin-a.internal:8443","https://origin-b.internal:8443"]`,
Enabled: true,
EnableHTTPS: false,
RedirectHTTP: false,
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 4); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
if route.SiteName != "app.example.com" {
t.Fatalf("unexpected site_name after migration: %s", route.SiteName)
}
if route.Domain != "app.example.com" {
t.Fatalf("unexpected domain mirror after migration: %s", route.Domain)
}
var domains []string
if err := json.Unmarshal([]byte(route.Domains), &domains); err != nil {
t.Fatalf("decode migrated domains: %v", err)
}
if len(domains) != 1 || domains[0] != "app.example.com" {
t.Fatalf("unexpected migrated domains: %#v", domains)
}
}
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteRateLimitFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-rate-limits.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV5{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v5: %v", err)
}
now := time.Now().UTC()
if err := db.Create(&legacyProxyRouteV5{
SiteName: "main-site",
Domain: "app.example.com",
Domains: `["app.example.com","www.example.com"]`,
OriginURL: "https://origin-a.internal:8443",
Upstreams: `["https://origin-a.internal:8443"]`,
Enabled: true,
EnableHTTPS: false,
RedirectHTTP: false,
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route v5: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 5); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
if route.LimitConnPerServer != 0 || route.LimitConnPerIP != 0 || route.LimitRate != "" {
t.Fatalf("expected new rate limit fields to default to disabled values, got %+v", route)
}
}
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteCertificateListFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-cert-ids.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV6{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v6: %v", err)
}
now := time.Now().UTC()
certID := uint(9)
if err := db.Create(&legacyProxyRouteV6{
SiteName: "secure-site",
Domain: "secure.example.com",
Domains: `["secure.example.com","www.secure.example.com"]`,
OriginURL: "https://origin-secure.internal:8443",
Upstreams: `["https://origin-secure.internal:8443"]`,
Enabled: true,
EnableHTTPS: true,
CertID: &certID,
RedirectHTTP: true,
LimitConnPerServer: 120,
LimitConnPerIP: 12,
LimitRate: "512k",
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route v6: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 6); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
if route.CertID == nil || *route.CertID != certID {
t.Fatalf("expected cert_id mirror to be preserved, got %+v", route.CertID)
}
var certIDs []uint
if err := json.Unmarshal([]byte(route.CertIDs), &certIDs); err != nil {
t.Fatalf("decode migrated cert_ids: %v", err)
}
if len(certIDs) != 1 || certIDs[0] != certID {
t.Fatalf("unexpected migrated cert_ids: %#v", certIDs)
}
}
func TestEnsureDatabaseSchemaUpToDateAddsProxyRouteDomainCertificateFields(t *testing.T) {
db := openBareTestSQLiteDB(t, "legacy-proxy-route-domain-cert-ids.db")
if err := registerSharding(db, "sqlite"); err != nil {
t.Fatalf("register sharding: %v", err)
}
if err := autoMigrateSchemaMetadata(db); err != nil {
t.Fatalf("auto migrate schema metadata: %v", err)
}
for _, item := range registeredModels() {
if _, ok := item.(*ProxyRoute); ok {
continue
}
if err := db.AutoMigrate(item); err != nil {
t.Fatalf("auto migrate supporting table: %v", err)
}
}
if err := db.AutoMigrate(&legacyProxyRouteV7{}); err != nil {
t.Fatalf("auto migrate legacy proxy_routes v7: %v", err)
}
now := time.Now().UTC()
certID := uint(9)
if err := db.Create(&legacyProxyRouteV7{
SiteName: "secure-site",
Domain: "secure.example.com",
Domains: `["secure.example.com","www.secure.example.com"]`,
OriginURL: "https://origin-secure.internal:8443",
Upstreams: `["https://origin-secure.internal:8443"]`,
Enabled: true,
EnableHTTPS: true,
CertID: &certID,
CertIDs: `[9]`,
RedirectHTTP: true,
LimitConnPerServer: 120,
LimitConnPerIP: 12,
LimitRate: "512k",
CacheEnabled: false,
CachePolicy: "",
CacheRules: `[]`,
CustomHeaders: `[]`,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("seed legacy proxy route v7: %v", err)
}
if err := saveDatabaseSchemaVersion(db, 7); err != nil {
t.Fatalf("save schema version: %v", err)
}
previousDB := DB
DB = db
t.Cleanup(func() {
DB = previousDB
})
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
}
var route ProxyRoute
if err := db.First(&route).Error; err != nil {
t.Fatalf("query migrated proxy route: %v", err)
}
var domainCertIDs []uint
if err := json.Unmarshal([]byte(route.DomainCertIDs), &domainCertIDs); err != nil {
t.Fatalf("decode migrated domain_cert_ids: %v", err)
}
if len(domainCertIDs) != 2 || domainCertIDs[0] != certID || domainCertIDs[1] != certID {
t.Fatalf("unexpected migrated domain_cert_ids: %#v", domainCertIDs)
}
}
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
db := openBareTestSQLiteDB(t, "failed-validation.db")
+701 -4
View File
@@ -1,7 +1,9 @@
package model
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net"
@@ -211,6 +213,582 @@ func validateDatabaseSchemaV4(db *gorm.DB, backend string) error {
return nil
}
func normalizeProxyRouteDomainForMigration(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func normalizeProxyRouteSiteNameForMigration(raw string, primaryDomain string) string {
siteName := strings.TrimSpace(raw)
if siteName != "" {
return siteName
}
return primaryDomain
}
func decodeProxyRouteDomainsForMigration(raw string, fallbackDomain string) ([]string, error) {
primaryDomain := normalizeProxyRouteDomainForMigration(fallbackDomain)
text := strings.TrimSpace(raw)
if text == "" {
if primaryDomain == "" {
return nil, fmt.Errorf("proxy route primary domain is empty")
}
return []string{primaryDomain}, nil
}
var domains []string
if err := json.Unmarshal([]byte(text), &domains); err != nil {
return nil, fmt.Errorf("decode proxy route domains failed: %w", err)
}
normalized := make([]string, 0, len(domains))
seen := make(map[string]struct{}, len(domains))
for _, domain := range domains {
item := normalizeProxyRouteDomainForMigration(domain)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
normalized = append(normalized, item)
}
if len(normalized) == 0 {
if primaryDomain == "" {
return nil, fmt.Errorf("proxy route domains are empty")
}
return []string{primaryDomain}, nil
}
if primaryDomain == "" {
primaryDomain = normalized[0]
}
if normalized[0] != primaryDomain {
rest := make([]string, 0, len(normalized))
for _, domain := range normalized {
if domain == primaryDomain {
continue
}
rest = append(rest, domain)
}
normalized = append([]string{primaryDomain}, rest...)
}
return normalized, nil
}
func backfillProxyRouteSiteFields(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&ProxyRoute{}) {
return nil
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "site_name") || !db.Migrator().HasColumn(&ProxyRoute{}, "domains") {
return nil
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for site field backfill failed: %w", err)
}
for _, route := range routes {
domains, err := decodeProxyRouteDomainsForMigration(route.Domains, route.Domain)
if err != nil {
return fmt.Errorf("normalize proxy route %d domains failed: %w", route.ID, err)
}
domainsJSON, err := json.Marshal(domains)
if err != nil {
return fmt.Errorf("encode proxy route %d domains failed: %w", route.ID, err)
}
primaryDomain := domains[0]
siteName := normalizeProxyRouteSiteNameForMigration(route.SiteName, primaryDomain)
updates := make(map[string]any, 3)
if route.Domain != primaryDomain {
updates["domain"] = primaryDomain
}
if route.SiteName != siteName {
updates["site_name"] = siteName
}
if strings.TrimSpace(route.Domains) != string(domainsJSON) {
updates["domains"] = string(domainsJSON)
}
if len(updates) == 0 {
continue
}
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("update proxy route %d site fields failed: %w", route.ID, err)
}
}
return nil
}
func ensureProxyRouteSiteNameUniqueIndex(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&ProxyRoute{}) || !db.Migrator().HasColumn(&ProxyRoute{}, "site_name") {
return nil
}
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_proxy_routes_site_name ON proxy_routes(site_name)`).Error
}
func decodeProxyRouteCertIDsForMigration(raw string, fallbackCertID *uint) ([]uint, error) {
text := strings.TrimSpace(raw)
if text == "" {
if fallbackCertID == nil || *fallbackCertID == 0 {
return []uint{}, nil
}
return []uint{*fallbackCertID}, nil
}
var certIDs []uint
if err := json.Unmarshal([]byte(text), &certIDs); err != nil {
return nil, fmt.Errorf("decode proxy route cert_ids failed: %w", err)
}
normalized := make([]uint, 0, len(certIDs))
seen := make(map[uint]struct{}, len(certIDs))
for _, certID := range certIDs {
if certID == 0 {
continue
}
if _, ok := seen[certID]; ok {
continue
}
seen[certID] = struct{}{}
normalized = append(normalized, certID)
}
if len(normalized) == 0 && fallbackCertID != nil && *fallbackCertID != 0 {
return []uint{*fallbackCertID}, nil
}
return normalized, nil
}
func backfillProxyRouteCertificateFields(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&ProxyRoute{}) {
return nil
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "cert_ids") {
return nil
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for certificate field backfill failed: %w", err)
}
for _, route := range routes {
certIDs, err := decodeProxyRouteCertIDsForMigration(route.CertIDs, route.CertID)
if err != nil {
return fmt.Errorf("normalize proxy route %d cert_ids failed: %w", route.ID, err)
}
certIDsJSON, err := json.Marshal(certIDs)
if err != nil {
return fmt.Errorf("encode proxy route %d cert_ids failed: %w", route.ID, err)
}
var primaryCertID *uint
if len(certIDs) > 0 {
primaryCertID = &certIDs[0]
}
updates := make(map[string]any, 2)
if strings.TrimSpace(route.CertIDs) != string(certIDsJSON) {
updates["cert_ids"] = string(certIDsJSON)
}
if (route.CertID == nil) != (primaryCertID == nil) || (route.CertID != nil && primaryCertID != nil && *route.CertID != *primaryCertID) {
updates["cert_id"] = primaryCertID
}
if len(updates) == 0 {
continue
}
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("update proxy route %d certificate fields failed: %w", route.ID, err)
}
}
return nil
}
func decodeProxyRouteDomainCertIDsForMigration(
raw string,
domainCount int,
) ([]uint, error) {
text := strings.TrimSpace(raw)
if text == "" {
return []uint{}, nil
}
var domainCertIDs []uint
if err := json.Unmarshal([]byte(text), &domainCertIDs); err != nil {
return nil, fmt.Errorf("decode proxy route domain_cert_ids failed: %w", err)
}
if len(domainCertIDs) == 0 {
return []uint{}, nil
}
if domainCount > 0 && len(domainCertIDs) != domainCount {
return nil, fmt.Errorf("proxy route domain_cert_ids length does not match domains")
}
normalized := make([]uint, len(domainCertIDs))
copy(normalized, domainCertIDs)
return normalized, nil
}
func parseLeafCertificateForMigration(certPEM string) (*x509.Certificate, error) {
var firstErr error
rest := []byte(certPEM)
for len(rest) > 0 {
block, remaining := pem.Decode(rest)
if block == nil {
break
}
rest = remaining
if block.Type != "CERTIFICATE" {
continue
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err == nil {
return certificate, nil
}
if firstErr == nil {
firstErr = err
}
}
if firstErr != nil {
return nil, firstErr
}
return nil, fmt.Errorf("parse certificate pem failed")
}
func deriveProxyRouteDomainCertIDsForMigration(
db *gorm.DB,
domains []string,
certIDs []uint,
) ([]uint, error) {
if len(certIDs) == 0 {
return []uint{}, nil
}
if len(certIDs) == 1 {
result := make([]uint, len(domains))
for index := range result {
result[index] = certIDs[0]
}
return result, nil
}
if len(certIDs) == len(domains) {
result := make([]uint, len(certIDs))
copy(result, certIDs)
return result, nil
}
var certificates []TLSCertificate
if err := db.Where("id IN ?", certIDs).Find(&certificates).Error; err != nil {
return nil, fmt.Errorf("load certificates for proxy route migration failed: %w", err)
}
certificateByID := make(map[uint]*x509.Certificate, len(certificates))
for index := range certificates {
leaf, err := parseLeafCertificateForMigration(certificates[index].CertPEM)
if err != nil {
return nil, fmt.Errorf("parse certificate %d for proxy route migration failed: %w", certificates[index].ID, err)
}
certificateByID[certificates[index].ID] = leaf
}
result := make([]uint, len(domains))
for domainIndex, domain := range domains {
if domainIndex < len(certIDs) {
certificate := certificateByID[certIDs[domainIndex]]
if certificate != nil && certificate.VerifyHostname(domain) == nil {
result[domainIndex] = certIDs[domainIndex]
continue
}
}
assigned := uint(0)
for _, certID := range certIDs {
certificate := certificateByID[certID]
if certificate != nil && certificate.VerifyHostname(domain) == nil {
assigned = certID
break
}
}
if assigned == 0 {
return nil, fmt.Errorf("no certificate covers domain %s", domain)
}
result[domainIndex] = assigned
}
return result, nil
}
func uniqueProxyRouteCertIDsFromDomainAssignments(domainCertIDs []uint) []uint {
unique := make([]uint, 0, len(domainCertIDs))
seen := make(map[uint]struct{}, len(domainCertIDs))
for _, certID := range domainCertIDs {
if certID == 0 {
continue
}
if _, ok := seen[certID]; ok {
continue
}
seen[certID] = struct{}{}
unique = append(unique, certID)
}
return unique
}
func backfillProxyRouteDomainCertificateFields(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database handle is nil")
}
if !db.Migrator().HasTable(&ProxyRoute{}) {
return nil
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "domain_cert_ids") {
return nil
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for domain certificate field backfill failed: %w", err)
}
for _, route := range routes {
domains, err := decodeProxyRouteDomainsForMigration(route.Domains, route.Domain)
if err != nil {
return fmt.Errorf("normalize proxy route %d domains failed: %w", route.ID, err)
}
certIDs, err := decodeProxyRouteCertIDsForMigration(route.CertIDs, route.CertID)
if err != nil {
return fmt.Errorf("normalize proxy route %d cert_ids failed: %w", route.ID, err)
}
domainCertIDs, err := decodeProxyRouteDomainCertIDsForMigration(
route.DomainCertIDs,
len(domains),
)
if err != nil {
return fmt.Errorf("normalize proxy route %d domain_cert_ids failed: %w", route.ID, err)
}
if len(domainCertIDs) == 0 && len(certIDs) > 0 {
domainCertIDs, err = deriveProxyRouteDomainCertIDsForMigration(
db,
domains,
certIDs,
)
if err != nil {
return fmt.Errorf("derive proxy route %d domain_cert_ids failed: %w", route.ID, err)
}
}
if !route.EnableHTTPS {
domainCertIDs = []uint{}
certIDs = []uint{}
}
domainCertIDsJSON, err := json.Marshal(domainCertIDs)
if err != nil {
return fmt.Errorf("encode proxy route %d domain_cert_ids failed: %w", route.ID, err)
}
normalizedCertIDs := uniqueProxyRouteCertIDsFromDomainAssignments(domainCertIDs)
if len(domainCertIDs) == 0 {
normalizedCertIDs = []uint{}
}
certIDsJSON, err := json.Marshal(normalizedCertIDs)
if err != nil {
return fmt.Errorf("encode proxy route %d cert_ids failed: %w", route.ID, err)
}
var primaryCertID *uint
if len(normalizedCertIDs) > 0 {
primaryCertID = &normalizedCertIDs[0]
}
updates := make(map[string]any, 3)
if strings.TrimSpace(route.DomainCertIDs) != string(domainCertIDsJSON) {
updates["domain_cert_ids"] = string(domainCertIDsJSON)
}
if strings.TrimSpace(route.CertIDs) != string(certIDsJSON) {
updates["cert_ids"] = string(certIDsJSON)
}
if (route.CertID == nil) != (primaryCertID == nil) || (route.CertID != nil && primaryCertID != nil && *route.CertID != *primaryCertID) {
updates["cert_id"] = primaryCertID
}
if len(updates) == 0 {
continue
}
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("update proxy route %d domain certificate fields failed: %w", route.ID, err)
}
}
return nil
}
func validateDatabaseSchemaV5(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV4(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "site_name") {
return fmt.Errorf("column proxy_routes.site_name is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "domains") {
return fmt.Errorf("column proxy_routes.domains is missing")
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for validation failed: %w", err)
}
siteNames := make(map[string]uint, len(routes))
domainOwners := make(map[string]uint, len(routes))
for _, route := range routes {
domains, err := decodeProxyRouteDomainsForMigration(route.Domains, route.Domain)
if err != nil {
return fmt.Errorf("proxy route %d domains are invalid: %w", route.ID, err)
}
if len(domains) == 0 {
return fmt.Errorf("proxy route %d domains are empty", route.ID)
}
if route.Domain != domains[0] {
return fmt.Errorf("proxy route %d primary domain mirror is invalid", route.ID)
}
siteName := normalizeProxyRouteSiteNameForMigration(route.SiteName, domains[0])
if siteName == "" {
return fmt.Errorf("proxy route %d site_name is empty", route.ID)
}
if existingID, ok := siteNames[siteName]; ok && existingID != route.ID {
return fmt.Errorf("proxy route site_name %s is duplicated", siteName)
}
siteNames[siteName] = route.ID
localSeen := make(map[string]struct{}, len(domains))
for _, domain := range domains {
if _, ok := localSeen[domain]; ok {
return fmt.Errorf("proxy route %d contains duplicated domain %s", route.ID, domain)
}
localSeen[domain] = struct{}{}
if existingID, ok := domainOwners[domain]; ok && existingID != route.ID {
return fmt.Errorf("proxy route domain %s is duplicated", domain)
}
domainOwners[domain] = route.ID
}
}
return nil
}
func validateDatabaseSchemaV6(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV5(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "limit_conn_per_server") {
return fmt.Errorf("column proxy_routes.limit_conn_per_server is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "limit_conn_per_ip") {
return fmt.Errorf("column proxy_routes.limit_conn_per_ip is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "limit_rate") {
return fmt.Errorf("column proxy_routes.limit_rate is missing")
}
return nil
}
func validateDatabaseSchemaV7(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV6(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "cert_ids") {
return fmt.Errorf("column proxy_routes.cert_ids is missing")
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for certificate validation failed: %w", err)
}
for _, route := range routes {
certIDs, err := decodeProxyRouteCertIDsForMigration(route.CertIDs, route.CertID)
if err != nil {
return fmt.Errorf("proxy route %d cert_ids are invalid: %w", route.ID, err)
}
if route.EnableHTTPS && len(certIDs) == 0 {
return fmt.Errorf("proxy route %d has https enabled without cert_ids", route.ID)
}
if !route.EnableHTTPS && route.RedirectHTTP {
return fmt.Errorf("proxy route %d enables redirect_http without https", route.ID)
}
if len(certIDs) == 0 {
if route.CertID != nil {
return fmt.Errorf("proxy route %d primary cert_id mirror is invalid", route.ID)
}
continue
}
if route.CertID == nil || *route.CertID != certIDs[0] {
return fmt.Errorf("proxy route %d primary cert_id mirror is invalid", route.ID)
}
}
return nil
}
func validateDatabaseSchemaV8(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV7(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "domain_cert_ids") {
return fmt.Errorf("column proxy_routes.domain_cert_ids is missing")
}
var routes []ProxyRoute
if err := db.Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("list proxy routes for domain certificate validation failed: %w", err)
}
for _, route := range routes {
domains, err := decodeProxyRouteDomainsForMigration(route.Domains, route.Domain)
if err != nil {
return fmt.Errorf("proxy route %d domains are invalid: %w", route.ID, err)
}
domainCertIDs, err := decodeProxyRouteDomainCertIDsForMigration(route.DomainCertIDs, len(domains))
if err != nil {
return fmt.Errorf("proxy route %d domain_cert_ids are invalid: %w", route.ID, err)
}
certIDs, err := decodeProxyRouteCertIDsForMigration(route.CertIDs, route.CertID)
if err != nil {
return fmt.Errorf("proxy route %d cert_ids are invalid: %w", route.ID, err)
}
if !route.EnableHTTPS {
if len(domainCertIDs) != 0 {
return fmt.Errorf("proxy route %d has domain_cert_ids while https is disabled", route.ID)
}
continue
}
if len(domainCertIDs) != len(domains) {
return fmt.Errorf("proxy route %d domain_cert_ids length is invalid", route.ID)
}
normalizedCertIDs := uniqueProxyRouteCertIDsFromDomainAssignments(domainCertIDs)
if len(normalizedCertIDs) == 0 {
return fmt.Errorf("proxy route %d has https enabled without domain certificate assignments", route.ID)
}
if !uintSlicesEqualForMigration(certIDs, normalizedCertIDs) {
return fmt.Errorf("proxy route %d cert_ids mirror is invalid", route.ID)
}
if route.CertID == nil || *route.CertID != normalizedCertIDs[0] {
return fmt.Errorf("proxy route %d primary cert_id mirror is invalid", route.ID)
}
}
return nil
}
func uintSlicesEqualForMigration(left []uint, right []uint) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func renameLegacyObservabilityShardTables(db *gorm.DB) error {
for _, baseTable := range shardedObservabilityBaseTables() {
for _, table := range observabilityShardTables(baseTable) {
@@ -549,11 +1127,118 @@ func migrateV4(db *gorm.DB, backend string) error {
return backfillOriginsFromProxyRoutes(db)
}
// migrateV5 upgrades proxy_routes to website-level identity fields by
// backfilling site_name and domains while keeping domain as the primary-domain
// compatibility mirror.
func migrateV5(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
return ensureProxyRouteSiteNameUniqueIndex(db)
}
// migrateV6 adds structured website-level rate limit fields to proxy_routes.
func migrateV6(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
return ensureProxyRouteSiteNameUniqueIndex(db)
}
// migrateV7 adds structured website-level certificate lists to proxy_routes
// while keeping cert_id as the primary certificate compatibility mirror.
func migrateV7(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
return err
}
return backfillProxyRouteCertificateFields(db)
}
// migrateV8 adds per-domain certificate assignments to proxy_routes while
// keeping cert_ids as the website-level compatibility mirror.
func migrateV8(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
return err
}
if err := backfillProxyRouteCertificateFields(db); err != nil {
return err
}
return backfillProxyRouteDomainCertificateFields(db)
}
// migrateV9 adds PoW (Proof-of-Work) anti-bot protection fields to proxy_routes.
func migrateV9(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
return err
}
if err := backfillProxyRouteCertificateFields(db); err != nil {
return err
}
return backfillProxyRouteDomainCertificateFields(db)
}
func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV8(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_enabled") {
return fmt.Errorf("column proxy_routes.pow_enabled is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
return fmt.Errorf("column proxy_routes.pow_config is missing")
}
return nil
}
func databaseSchemaMigrations() []databaseSchemaMigration {
return []databaseSchemaMigration{
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
{fromVersion: 2, toVersion: 3, migrate: migrateV3, validate: validateDatabaseSchemaV3},
{fromVersion: 3, toVersion: 4, migrate: migrateV4, validate: validateDatabaseSchemaV4},
{fromVersion: 4, toVersion: 5, migrate: migrateV5, validate: validateDatabaseSchemaV5},
{fromVersion: 5, toVersion: 6, migrate: migrateV6, validate: validateDatabaseSchemaV6},
{fromVersion: 6, toVersion: 7, migrate: migrateV7, validate: validateDatabaseSchemaV7},
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
}
}
@@ -618,13 +1303,25 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := migrateSQLiteDataIfNeeded(db, backend); err != nil {
return err
}
if err := validateDatabaseSchemaV4(db, backend); err != nil {
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
return err
}
if err := backfillProxyRouteCertificateFields(db); err != nil {
return err
}
if err := backfillProxyRouteDomainCertificateFields(db); err != nil {
return err
}
if err := validateDatabaseSchemaV9(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
+33 -12
View File
@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type Option struct {
@@ -110,19 +112,38 @@ func InitOptionMap() {
}
func UpdateOption(key string, value string) error {
// Save to database first
option := Option{
Key: key,
return UpdateOptions([]Option{{
Key: key,
Value: value,
}})
}
func UpdateOptions(options []Option) error {
if len(options) == 0 {
return nil
}
if err := DB.Transaction(func(tx *gorm.DB) error {
for _, item := range options {
option := Option{
Key: item.Key,
}
if err := tx.FirstOrCreate(&option, Option{Key: item.Key}).Error; err != nil {
return err
}
option.Value = item.Value
if err := tx.Save(&option).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
for _, item := range options {
updateOptionMap(item.Key, item.Value)
}
// https://gorm.io/docs/update.html#Save-All-Fields
DB.FirstOrCreate(&option, Option{Key: key})
option.Value = value
// Save is a combination function.
// If save value does not contain primary key, it will execute Create,
// otherwise it will execute Update (with all fields).
DB.Save(&option)
// Update OptionMap
updateOptionMap(key, value)
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package model
import (
"openflare/common"
"testing"
)
func TestInitOptionMapDefaultsRegisterDisabled(t *testing.T) {
previousRegisterEnabled := common.RegisterEnabled
previousOptionMap := common.OptionMap
previousDB := DB
t.Cleanup(func() {
common.RegisterEnabled = previousRegisterEnabled
common.OptionMap = previousOptionMap
DB = previousDB
})
DB = openTestSQLiteDB(t, "options-defaults.db")
common.RegisterEnabled = false
common.OptionMap = nil
InitOptionMap()
if got := common.OptionMap["RegisterEnabled"]; got != "false" {
t.Fatalf("expected RegisterEnabled default to be false, got %q", got)
}
if common.RegisterEnabled {
t.Fatal("expected RegisterEnabled to remain false after InitOptionMap")
}
}
+50 -32
View File
@@ -3,23 +3,32 @@ package model
import "time"
type ProxyRoute struct {
ID uint `json:"id" gorm:"primaryKey"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
SiteName string `json:"site_name" gorm:"size:255;not null;default:''"`
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
Domains string `json:"domains" gorm:"type:text;not null;default:'[]'"`
OriginID *uint `json:"origin_id" gorm:"index"`
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
OriginHost string `json:"origin_host" gorm:"size:255"`
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
EnableHTTPS bool `json:"enable_https" gorm:"column:enable_https;not null;default:false"`
CertID *uint `json:"cert_id"`
CertIDs string `json:"cert_ids" gorm:"type:text;not null;default:'[]'"`
DomainCertIDs string `json:"domain_cert_ids" gorm:"type:text;not null;default:'[]'"`
RedirectHTTP bool `json:"redirect_http" gorm:"not null;default:false"`
LimitConnPerServer int `json:"limit_conn_per_server" gorm:"not null;default:0"`
LimitConnPerIP int `json:"limit_conn_per_ip" gorm:"not null;default:0"`
LimitRate string `json:"limit_rate" gorm:"size:32;not null;default:''"`
CacheEnabled bool `json:"cache_enabled" gorm:"not null;default:false"`
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func ListProxyRoutes() (routes []*ProxyRoute, err error) {
@@ -28,7 +37,7 @@ func ListProxyRoutes() (routes []*ProxyRoute, err error) {
}
func GetEnabledProxyRoutes() (routes []*ProxyRoute, err error) {
err = DB.Where("enabled = ?", true).Order("domain asc").Find(&routes).Error
err = DB.Where("enabled = ?", true).Order("site_name asc").Order("domain asc").Find(&routes).Error
return routes, err
}
@@ -49,20 +58,29 @@ func (route *ProxyRoute) Insert() error {
func (route *ProxyRoute) Update() error {
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
"domain": route.Domain,
"origin_id": route.OriginID,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"redirect_http": route.RedirectHTTP,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"remark": route.Remark,
"site_name": route.SiteName,
"domain": route.Domain,
"domains": route.Domains,
"origin_id": route.OriginID,
"origin_url": route.OriginURL,
"origin_host": route.OriginHost,
"upstreams": route.Upstreams,
"enabled": route.Enabled,
"enable_https": route.EnableHTTPS,
"cert_id": route.CertID,
"cert_ids": route.CertIDs,
"domain_cert_ids": route.DomainCertIDs,
"redirect_http": route.RedirectHTTP,
"limit_conn_per_server": route.LimitConnPerServer,
"limit_conn_per_ip": route.LimitConnPerIP,
"limit_rate": route.LimitRate,
"cache_enabled": route.CacheEnabled,
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"remark": route.Remark,
}).Error
}
+2
View File
@@ -54,6 +54,7 @@ func SetApiRouter(router *gin.Engine) {
{
optionRoute.GET("/", controller.GetOptions)
optionRoute.POST("/update", controller.UpdateOption)
optionRoute.POST("/update-batch", controller.UpdateOptionsBatch)
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
}
@@ -78,6 +79,7 @@ func SetApiRouter(router *gin.Engine) {
proxyRoute.Use(middleware.AdminAuth())
{
proxyRoute.GET("/", controller.GetProxyRoutes)
proxyRoute.GET("/:id", controller.GetProxyRoute)
proxyRoute.POST("/", controller.CreateProxyRoute)
proxyRoute.POST("/:id/update", controller.UpdateProxyRoute)
proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute)
+4 -4
View File
@@ -55,7 +55,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
"remark": "primary route",
}
resp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", createBody)
var createdRoute model.ProxyRoute
var createdRoute service.ProxyRouteView
decodeResponseData(t, resp, &createdRoute)
if createdRoute.Domain != "app.example.com" {
t.Fatalf("unexpected created route domain: %s", createdRoute.Domain)
@@ -74,7 +74,7 @@ func TestPhase1PublishLifecycle(t *testing.T) {
}
resp = performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []model.ProxyRoute
var routes []service.ProxyRouteView
decodeResponseData(t, resp, &routes)
if len(routes) != 1 {
t.Fatalf("expected 1 route, got %d", len(routes))
@@ -289,7 +289,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
"redirect_http": true,
"remark": "https route",
})
var route model.ProxyRoute
var route service.ProxyRouteView
decodeResponseData(t, resp, &route)
if !route.EnableHTTPS || route.CertID == nil || *route.CertID != manualCertificate.ID {
t.Fatal("expected route to persist https certificate binding")
@@ -324,7 +324,7 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
}
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []model.ProxyRoute
var routes []service.ProxyRouteView
decodeResponseData(t, listResp, &routes)
if len(routes) != 1 || !routes[0].EnableHTTPS || routes[0].CertID == nil || *routes[0].CertID != manualCertificate.ID || !routes[0].RedirectHTTP {
t.Fatalf("expected route list to reflect https update, got %+v", routes)
+196 -16
View File
@@ -41,21 +41,25 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
loginCookie := loginAsRoot(t, engine)
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "GlobalApiRateLimitNum",
"value": "450",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "GlobalApiRateLimitDuration",
"value": "240",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "CriticalRateLimitNum",
"value": "150",
})
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update", map[string]any{
"key": "CriticalRateLimitDuration",
"value": "900",
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update-batch", map[string]any{
"options": []map[string]any{
{
"key": "GlobalApiRateLimitNum",
"value": "450",
},
{
"key": "GlobalApiRateLimitDuration",
"value": "240",
},
{
"key": "CriticalRateLimitNum",
"value": "150",
},
{
"key": "CriticalRateLimitDuration",
"value": "900",
},
},
})
if common.GlobalApiRateLimitNum != 450 {
@@ -88,6 +92,115 @@ func TestPhase2RateLimitOptionsHotReload(t *testing.T) {
}
}
func TestPhase2BatchOptionUpdateIsAtomic(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
model.InitOptionMap()
oldGlobalAPI := common.GlobalApiRateLimitNum
t.Cleanup(func() {
common.GlobalApiRateLimitNum = oldGlobalAPI
})
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
payload, err := json.Marshal(map[string]any{
"options": []map[string]any{
{
"key": "GlobalApiRateLimitNum",
"value": "451",
},
{
"key": "CriticalRateLimitDuration",
"value": "1800",
},
},
})
if err != nil {
t.Fatalf("failed to marshal batch payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/option/update-batch", bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(loginCookie)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String())
}
var resp apiResponse
if err = json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Success {
t.Fatal("expected invalid batch update to fail")
}
if common.GlobalApiRateLimitNum != oldGlobalAPI {
t.Fatalf("expected GlobalApiRateLimitNum to remain %d after failed batch, got %d", oldGlobalAPI, common.GlobalApiRateLimitNum)
}
resp = performSessionJSONRequest(t, engine, loginCookie, http.MethodGet, "/api/option/", nil)
var options []model.Option
decodeResponseData(t, resp, &options)
optionMap := make(map[string]string, len(options))
for _, option := range options {
optionMap[option.Key] = option.Value
}
if optionMap["GlobalApiRateLimitNum"] == "451" {
t.Fatal("expected failed batch update to avoid persisting partial values")
}
}
func TestPhase2BatchOptionUpdateValidatesMergedState(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
model.InitOptionMap()
oldGitHubClientID := common.GitHubClientId
oldGitHubOAuthEnabled := common.GitHubOAuthEnabled
t.Cleanup(func() {
common.GitHubClientId = oldGitHubClientID
common.GitHubOAuthEnabled = oldGitHubOAuthEnabled
})
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
loginCookie := loginAsRoot(t, engine)
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/option/update-batch", map[string]any{
"options": []map[string]any{
{
"key": "GitHubClientId",
"value": "client-id-from-batch",
},
{
"key": "GitHubOAuthEnabled",
"value": "true",
},
},
})
if common.GitHubClientId != "client-id-from-batch" {
t.Fatalf("expected GitHubClientId to be updated from batch, got %q", common.GitHubClientId)
}
if !common.GitHubOAuthEnabled {
t.Fatal("expected GitHubOAuthEnabled to be enabled by merged batch state")
}
}
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
t.Helper()
payload, err := json.Marshal(map[string]any{
@@ -460,7 +573,7 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
{"key": "X-Trace-Id", "value": "$request_id"},
},
})
var createdRoute model.ProxyRoute
var createdRoute service.ProxyRouteView
decodeResponseData(t, createResp, &createdRoute)
if !strings.Contains(createdRoute.CustomHeaders, "X-Trace-Id") {
t.Fatalf("expected custom headers to be stored as json, got %s", createdRoute.CustomHeaders)
@@ -468,6 +581,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
if createdRoute.OriginHost != "preview-origin.internal" {
t.Fatalf("expected origin_host to be stored, got %s", createdRoute.OriginHost)
}
if createdRoute.SiteName != "preview.example.com" || createdRoute.PrimaryDomain != "preview.example.com" || createdRoute.DomainCount != 1 {
t.Fatalf("expected website identity fields in create response, got %+v", createdRoute)
}
performJSONRequest(t, engine, token, http.MethodPost, "/api/config-versions/publish", nil)
@@ -491,6 +607,9 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
var preview map[string]any
decodeResponseData(t, previewResp, &preview)
renderedConfig, _ := preview["rendered_config"].(string)
if websiteCount, ok := preview["website_count"].(float64); !ok || int(websiteCount) != 2 {
t.Fatalf("expected preview website_count=2, got %#v", preview["website_count"])
}
if !strings.Contains(renderedConfig, `proxy_set_header X-Release "candidate";`) {
t.Fatalf("expected preview endpoint to return custom header, got %s", renderedConfig)
}
@@ -515,6 +634,67 @@ func TestPhase2CustomHeadersPreviewAndDiffLifecycle(t *testing.T) {
if !ok || len(addedDomains) != 1 || addedDomains[0].(string) != "new-preview.example.com" {
t.Fatalf("unexpected added domains: %#v", diff["added_domains"])
}
modifiedSites, ok := diff["modified_sites"].([]any)
if !ok || len(modifiedSites) != 1 || modifiedSites[0].(string) != "preview.example.com" {
t.Fatalf("unexpected modified sites: %#v", diff["modified_sites"])
}
addedSites, ok := diff["added_sites"].([]any)
if !ok || len(addedSites) != 1 || addedSites[0].(string) != "new-preview.example.com" {
t.Fatalf("unexpected added sites: %#v", diff["added_sites"])
}
}
func TestPhase2ProxyRouteWebsiteDetailAndLimits(t *testing.T) {
gin.SetMode(gin.TestMode)
common.RedisEnabled = false
setupTestDB(t)
engine := gin.New()
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
router.SetApiRouter(engine)
token := prepareRootToken(t)
createResp := performJSONRequest(t, engine, token, http.MethodPost, "/api/proxy-routes/", map[string]any{
"site_name": "marketing-site",
"domains": []string{"app.example.com", "www.example.com"},
"origin_url": "https://origin.internal",
"enabled": true,
"limit_conn_per_server": 120,
"limit_conn_per_ip": 12,
"limit_rate": "512K",
})
var createdRoute service.ProxyRouteView
decodeResponseData(t, createResp, &createdRoute)
if createdRoute.SiteName != "marketing-site" || createdRoute.PrimaryDomain != "app.example.com" {
t.Fatalf("unexpected create payload: %+v", createdRoute)
}
if createdRoute.DomainCount != 2 || len(createdRoute.Domains) != 2 || createdRoute.Domains[1] != "www.example.com" {
t.Fatalf("expected multi-domain website view, got %+v", createdRoute)
}
if createdRoute.LimitConnPerServer != 120 || createdRoute.LimitConnPerIP != 12 || createdRoute.LimitRate != "512k" {
t.Fatalf("expected normalized rate limit fields, got %+v", createdRoute)
}
if len(createdRoute.UpstreamList) != 1 || createdRoute.UpstreamList[0] != "https://origin.internal" {
t.Fatalf("expected structured upstream list, got %+v", createdRoute.UpstreamList)
}
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/"+toString(createdRoute.ID), nil)
var detail service.ProxyRouteView
decodeResponseData(t, detailResp, &detail)
if detail.ID != createdRoute.ID || detail.SiteName != "marketing-site" || detail.LimitRate != "512k" {
t.Fatalf("unexpected detail response: %+v", detail)
}
if len(detail.Domains) != 2 || detail.Domains[0] != "app.example.com" || detail.Domains[1] != "www.example.com" {
t.Fatalf("expected detail response to expose full domain list, got %+v", detail.Domains)
}
listResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/proxy-routes/", nil)
var routes []service.ProxyRouteView
decodeResponseData(t, listResp, &routes)
if len(routes) != 1 || routes[0].SiteName != "marketing-site" || routes[0].LimitConnPerServer != 120 {
t.Fatalf("unexpected proxy route list response: %+v", routes)
}
}
func TestPhase2GlobalDiscoveryRegistration(t *testing.T) {
+4 -2
View File
@@ -217,7 +217,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
return nil, err
}
}
supportFiles = filterCertificateSupportFiles(supportFiles)
supportFiles = filterAgentSupportFiles(supportFiles)
slog.Debug("agent fetched active config", "version", version.Version, "checksum", version.Checksum)
return &AgentConfigResponse{
Version: version.Version,
@@ -230,7 +230,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
}, nil
}
func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
func filterAgentSupportFiles(files []SupportFile) []SupportFile {
if len(files) == 0 {
return nil
}
@@ -240,6 +240,8 @@ func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
switch {
case strings.HasSuffix(path, ".crt"), strings.HasSuffix(path, ".key"), strings.HasSuffix(path, ".pem"):
filtered = append(filtered, file)
case path == "pow_config.json":
filtered = append(filtered, file)
}
}
return filtered
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"strings"
"testing"
)
func TestGetActiveConfigForAgentIncludesPoWConfig(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-agent.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":4,"algorithm":"fast","session_ttl":86400,"challenge_ttl":300,"whitelist":{"paths":["/.well-known/*","/favicon.ico","/robots.txt"],"user_agents":["Googlebot","bingbot","Baiduspider"]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
foundPowConfig := false
for _, file := range activeConfig.SupportFiles {
if file.Path != "pow_config.json" {
continue
}
foundPowConfig = true
if file.Content == "" {
t.Fatal("expected pow_config.json content to be populated")
}
}
if !foundPowConfig {
t.Fatal("expected agent config to include pow_config.json support file")
}
}
func TestGetActiveConfigForAgentUsesTenMinutePoWSessionDefault(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-default.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
for _, file := range activeConfig.SupportFiles {
if file.Path == "pow_config.json" {
if !strings.Contains(file.Content, `"session_ttl":600`) {
t.Fatalf("expected default PoW session TTL to be 600 seconds, got %s", file.Content)
}
return
}
}
t.Fatal("expected agent config to include pow_config.json support file")
}
+582 -53
View File
@@ -35,6 +35,7 @@ type ConfigPreviewResult struct {
SupportFiles []SupportFile `json:"support_files"`
Checksum string `json:"checksum"`
RouteCount int `json:"route_count"`
WebsiteCount int `json:"website_count"`
}
type ConfigVersionSummary = model.ConfigVersionSummary
@@ -43,12 +44,17 @@ type ConfigVersionDetail = model.ConfigVersion
type ConfigDiffResult struct {
ActiveVersion string `json:"active_version,omitempty"`
AddedSites []string `json:"added_sites"`
RemovedSites []string `json:"removed_sites"`
ModifiedSites []string `json:"modified_sites"`
AddedDomains []string `json:"added_domains"`
RemovedDomains []string `json:"removed_domains"`
ModifiedDomains []string `json:"modified_domains"`
MainConfigChanged bool `json:"main_config_changed"`
ChangedOptionKeys []string `json:"changed_option_keys"`
ChangedOptionDetails []ConfigOptionDiffItem `json:"changed_option_details"`
CurrentWebsiteCount int `json:"current_website_count"`
ActiveWebsiteCount int `json:"active_website_count"`
}
type ConfigOptionDiffItem struct {
@@ -58,19 +64,28 @@ type ConfigOptionDiffItem struct {
}
type snapshotRoute struct {
Domain string `json:"domain"`
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"`
RedirectHTTP bool `json:"redirect_http"`
CacheEnabled bool `json:"cache_enabled"`
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
Remark string `json:"remark,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 []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
Remark string `json:"remark,omitempty"`
}
type routeCacheConfig struct {
@@ -79,6 +94,12 @@ type routeCacheConfig struct {
Rules []string
}
type routeLimitConfig struct {
LimitConnPerServer int
LimitConnPerIP int
LimitRate string
}
type routeUpstreamConfig struct {
Name string
Scheme string
@@ -205,6 +226,7 @@ func PreviewConfigVersion() (*ConfigPreviewResult, error) {
SupportFiles: bundle.SupportFiles,
Checksum: bundle.Checksum,
RouteCount: len(bundle.Routes),
WebsiteCount: len(bundle.SnapshotRoutes),
}, nil
}
@@ -214,21 +236,29 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
return nil, err
}
result := &ConfigDiffResult{
AddedSites: []string{},
RemovedSites: []string{},
ModifiedSites: []string{},
AddedDomains: []string{},
RemovedDomains: []string{},
ModifiedDomains: []string{},
ChangedOptionKeys: []string{},
ChangedOptionDetails: []ConfigOptionDiffItem{},
CurrentWebsiteCount: len(bundle.SnapshotRoutes),
}
activeVersion, err := model.GetActiveConfigVersion()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
for _, route := range bundle.SnapshotRoutes {
result.AddedDomains = append(result.AddedDomains, route.Domain)
result.AddedSites = append(result.AddedSites, route.SiteName)
result.AddedDomains = append(result.AddedDomains, route.Domains...)
}
result.MainConfigChanged = true
result.ChangedOptionKeys = openRestyOptionKeys()
result.ChangedOptionDetails = buildInitialOpenRestyOptionDiffs(bundle.OpenRestyConfig)
sort.Strings(result.AddedSites)
sort.Strings(result.AddedDomains)
sort.Strings(result.ChangedOptionKeys)
return result, nil
}
return nil, err
@@ -238,14 +268,26 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
if err != nil {
return nil, err
}
currentMap := make(map[string]snapshotRoute, len(bundle.SnapshotRoutes))
for _, route := range bundle.SnapshotRoutes {
currentMap[route.Domain] = route
result.ActiveWebsiteCount = len(activeSnapshot.Routes)
currentSiteMap := flattenSnapshotRoutesBySite(bundle.SnapshotRoutes)
activeSiteMap := flattenSnapshotRoutesBySite(activeSnapshot.Routes)
for siteName, currentRoute := range currentSiteMap {
activeRoute, ok := activeSiteMap[siteName]
if !ok {
result.AddedSites = append(result.AddedSites, siteName)
continue
}
if !snapshotRouteConfigEqual(activeRoute, currentRoute) {
result.ModifiedSites = append(result.ModifiedSites, siteName)
}
}
activeMap := make(map[string]snapshotRoute, len(activeSnapshot.Routes))
for _, route := range activeSnapshot.Routes {
activeMap[route.Domain] = route
for siteName := range activeSiteMap {
if _, ok := currentSiteMap[siteName]; !ok {
result.RemovedSites = append(result.RemovedSites, siteName)
}
}
currentMap := flattenSnapshotRoutesByDomain(bundle.SnapshotRoutes)
activeMap := flattenSnapshotRoutesByDomain(activeSnapshot.Routes)
for domain, currentRoute := range currentMap {
activeRoute, ok := activeMap[domain]
if !ok {
@@ -264,6 +306,9 @@ func DiffConfigVersion() (*ConfigDiffResult, error) {
result.MainConfigChanged = activeVersion.MainConfig != bundle.MainConfig
result.ChangedOptionDetails = diffOpenRestyOptionDetails(activeSnapshot.OpenRestyConfig, bundle.OpenRestyConfig)
result.ChangedOptionKeys = extractOptionDiffKeys(result.ChangedOptionDetails)
sort.Strings(result.AddedSites)
sort.Strings(result.RemovedSites)
sort.Strings(result.ModifiedSites)
sort.Strings(result.AddedDomains)
sort.Strings(result.RemovedDomains)
sort.Strings(result.ModifiedDomains)
@@ -386,7 +431,13 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes)
if err != nil {
return nil, err
}
supportFiles = append(supportFiles, powSupportFiles...)
mainConfig := renderMainConfig(openRestyConfig)
supportFiles = append(supportFiles, SupportFile{Path: "pow_config.json", Content: powConfigJSON})
return &configBundle{
Routes: routes,
SnapshotRoutes: snapshotRoutes,
@@ -403,6 +454,10 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
items := make([]snapshotRoute, 0, len(routes))
for _, route := range routes {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return nil, fmt.Errorf("route %s domains are invalid", route.Domain)
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
@@ -415,25 +470,70 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, fmt.Errorf("路由 %s PoW 配置无效", route.Domain)
}
if !route.PoWEnabled {
powConfig = nil
}
items = append(items, snapshotRoute{
Domain: route.Domain,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: route.CertID,
RedirectHTTP: route.RedirectHTTP,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
Remark: route.Remark,
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
Domain: domains[0],
Domains: domains,
OriginURL: route.OriginURL,
OriginHost: route.OriginHost,
Upstreams: upstreams,
Enabled: route.Enabled,
EnableHTTPS: route.EnableHTTPS,
CertID: route.CertID,
CertIDs: mustDecodeSnapshotCertIDs(route),
DomainCertIDs: mustDecodeSnapshotDomainCertIDs(route, domains),
RedirectHTTP: route.RedirectHTTP,
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
Remark: route.Remark,
})
}
return items, nil
}
func mustDecodeSnapshotCertIDs(route *model.ProxyRoute) []uint {
if route == nil {
return []uint{}
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return []uint{}
}
return certIDs
}
func mustDecodeSnapshotDomainCertIDs(
route *model.ProxyRoute,
domains []string,
) []uint {
if route == nil {
return []uint{}
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return []uint{}
}
domainCertIDs, err := resolveProxyRouteDomainCertIDs(route, domains, certIDs)
if err != nil {
return []uint{}
}
return domainCertIDs
}
func parseSnapshotDocument(snapshotJSON string) (*snapshotDocument, error) {
text := strings.TrimSpace(snapshotJSON)
if text == "" {
@@ -459,10 +559,36 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
return []snapshotRoute{}
}
for index := range routes {
normalizedDomains, err := decodeStoredDomains("", routes[index].Domain)
if len(routes[index].Domains) > 0 {
normalizedDomains, err = normalizeProxyRouteDomains(routes[index].Domains)
}
if err == nil && len(normalizedDomains) > 0 {
routes[index].Domains = normalizedDomains
routes[index].Domain = normalizedDomains[0]
routes[index].SiteName = normalizeProxyRouteSiteNameInput(
&model.ProxyRoute{SiteName: routes[index].SiteName},
routes[index].SiteName,
normalizedDomains[0],
)
}
normalizedHeaders, err := normalizeCustomHeaders(routes[index].CustomHeaders)
if err == nil {
routes[index].CustomHeaders = normalizedHeaders
}
normalizedCertIDs, primaryCertID, err := normalizeSnapshotCertificateIDs(routes[index].CertID, routes[index].CertIDs)
if err == nil {
routes[index].CertID = primaryCertID
routes[index].CertIDs = normalizedCertIDs
}
normalizedDomainCertIDs, err := normalizeSnapshotDomainCertificateIDs(
routes[index].Domains,
routes[index].CertIDs,
routes[index].DomainCertIDs,
)
if err == nil {
routes[index].DomainCertIDs = normalizedDomainCertIDs
}
normalizedUpstreams, err := normalizeUpstreams(routes[index].OriginURL, routes[index].Upstreams)
if err == nil {
routes[index].OriginURL = normalizedUpstreams[0]
@@ -473,14 +599,57 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
routes[index].CachePolicy = normalizeCachePolicy(routes[index].CacheEnabled, routes[index].CachePolicy)
routes[index].CacheRules = normalizedCacheRules
}
normalizedLimitRate, err := normalizeProxyRouteLimitRate(routes[index].LimitRate)
if err == nil {
routes[index].LimitRate = normalizedLimitRate
}
if routes[index].PoWEnabled {
raw, err := json.Marshal(routes[index].PoWConfig)
if err == nil {
normalizedPoWConfig, err := normalizePoWConfig(true, string(raw))
if err == nil {
routes[index].PoWConfig = &normalizedPoWConfig
}
}
} else {
routes[index].PoWConfig = nil
}
}
return routes
}
func flattenSnapshotRoutesBySite(routes []snapshotRoute) map[string]snapshotRoute {
siteMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
siteMap[route.SiteName] = route
}
return siteMap
}
func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRoute {
domainMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
for _, domain := range route.Domains {
item := route
item.Domain = domain
domainMap[domain] = item
}
}
return domainMap
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintPointerEqual(left.CertID, right.CertID) {
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 || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
return false
}
for index := range left.Domains {
if left.Domains[index] != right.Domains[index] {
return false
}
}
if len(left.Upstreams) != len(right.Upstreams) {
return false
}
@@ -505,6 +674,41 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
return false
}
}
if !snapshotPoWConfigEqual(left.PoWConfig, right.PoWConfig) {
return false
}
return true
}
func snapshotPoWConfigEqual(left *ProxyRoutePoWConfig, right *ProxyRoutePoWConfig) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.Difficulty == right.Difficulty &&
left.Algorithm == right.Algorithm &&
left.SessionTTL == right.SessionTTL &&
left.ChallengeTTL == right.ChallengeTTL &&
stringSliceEqual(left.Whitelist.IPs, right.Whitelist.IPs) &&
stringSliceEqual(left.Whitelist.IPCidrs, right.Whitelist.IPCidrs) &&
stringSliceEqual(left.Whitelist.Paths, right.Whitelist.Paths) &&
stringSliceEqual(left.Whitelist.PathRegexes, right.Whitelist.PathRegexes) &&
stringSliceEqual(left.Whitelist.UserAgents, right.Whitelist.UserAgents) &&
stringSliceEqual(left.Blacklist.IPs, right.Blacklist.IPs) &&
stringSliceEqual(left.Blacklist.IPCidrs, right.Blacklist.IPCidrs) &&
stringSliceEqual(left.Blacklist.Paths, right.Blacklist.Paths) &&
stringSliceEqual(left.Blacklist.PathRegexes, right.Blacklist.PathRegexes) &&
stringSliceEqual(left.Blacklist.UserAgents, right.Blacklist.UserAgents)
}
func stringSliceEqual(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
@@ -658,6 +862,15 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
builder.WriteString("# This file is generated by OpenFlare. Do not edit manually.\n")
supportFiles := make([]SupportFile, 0)
for _, route := range routes {
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return "", nil, fmt.Errorf("route %s domains are invalid", route.Domain)
}
serverNames := renderServerNames(domains)
displayName := route.SiteName
if strings.TrimSpace(displayName) == "" {
displayName = domains[0]
}
customHeaders, err := decodeStoredCustomHeaders(route.CustomHeaders)
if err != nil {
return "", nil, fmt.Errorf("路由 %s 自定义请求头无效", route.Domain)
@@ -675,31 +888,96 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
Policy: route.CachePolicy,
Rules: cacheRules,
}
limitConfig := routeLimitConfig{
LimitConnPerServer: route.LimitConnPerServer,
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
}
upstreamConfig := buildRouteUpstreamConfig(route, upstreams)
if upstreamConfig.UsesNamedUpstream {
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
continue
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return "", nil, fmt.Errorf("route %s cert_ids are invalid: %w", route.Domain, err)
}
domainCertIDs, err := resolveProxyRouteDomainCertIDs(route, domains, certIDs)
if err != nil {
return "", nil, fmt.Errorf("route %s domain_cert_ids are invalid: %w", route.Domain, err)
}
if route.CertID == nil || *route.CertID == 0 {
return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain)
}
certificate, err := model.GetTLSCertificateByID(*route.CertID)
if len(certIDs) == 0 {
return "", nil, fmt.Errorf("路由 %s 未配置证书", route.Domain)
}
certificates, err := loadTLSCertificates(certIDs)
if err != nil {
return "", nil, fmt.Errorf("路由 %s 关联证书不存在", route.Domain)
return "", nil, fmt.Errorf("route %s certificate lookup failed: %w", route.Domain, err)
}
supportFiles = append(supportFiles,
SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)},
SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)},
)
certificateByID := make(map[uint]*model.TLSCertificate, len(certificates))
for _, certificate := range certificates {
if certificate == nil {
continue
}
certificateByID[certificate.ID] = certificate
supportFiles = append(supportFiles,
SupportFile{Path: certificateCertFileName(certificate.ID), Content: normalizePEM(certificate.CertPEM)},
SupportFile{Path: certificateKeyFileName(certificate.ID), Content: normalizePEM(certificate.KeyPEM)},
)
}
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
}
certificate := certificateByID[certID]
if certificate == nil {
return "", nil, fmt.Errorf("route %s certificate %d does not exist", route.Domain, certID)
}
if err := validateCertificateCoverage(certificate, assignedDomains); err != nil {
return "", nil, fmt.Errorf("site %s certificate validation failed: %w", displayName, err)
}
}
if route.RedirectHTTP {
builder.WriteString(renderHTTPRedirectServer(route.Domain))
if len(httpOnlyDomains) > 0 {
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
if len(assignedDomains) == 0 {
continue
}
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
}
} else {
builder.WriteString(renderHTTPProxyServer(route.Domain, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
if len(assignedDomains) == 0 {
continue
}
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
builder.WriteString(renderHTTPSServer(route.Domain, route.OriginURL, route.OriginHost, certificate.ID, customHeaders, cacheConfig, upstreamConfig, cfg))
}
return builder.String(), dedupeSupportFiles(supportFiles), nil
}
@@ -772,7 +1050,8 @@ func renderTemplateDirective(enabled bool, statement string) string {
}
func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string {
lines := make([]string, 0, 8)
lines := make([]string, 0, 12)
lines = append(lines, renderOpenRestyLimitZoneBlock())
if !cfg.CacheEnabled {
lines = append(lines, renderOpenRestyObservabilityTemplateBlock())
return strings.Join(lines, "")
@@ -789,6 +1068,14 @@ func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string {
return strings.Join(lines, "")
}
func renderOpenRestyLimitZoneBlock() string {
return strings.Join([]string{
" limit_conn_zone $server_name zone=openflare_conn_per_server:10m;",
" limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;",
"",
}, "\n")
}
func onOff(value bool) string {
if value {
return "on"
@@ -796,6 +1083,90 @@ func onOff(value bool) string {
return "off"
}
const nginxPowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
func renderPowAccessBlock(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf(" access_by_lua_file %s/pow/check.lua;\n", nginxLuaDirPlaceholder)
}
func renderPowLocationBlocks(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf("\n location = %spass-challenge {\n content_by_lua_file %s/pow/verify.lua;\n }\n\n location = %smake-challenge {\n content_by_lua_file %s/pow/challenge.lua;\n }\n\n", anubisAPIPrefix, nginxLuaDirPlaceholder, anubisAPIPrefix, nginxLuaDirPlaceholder)
}
func renderPowStaticLocationBlock(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf(" location %s {\n alias %s/;\n types {\n text/css css;\n application/javascript js mjs;\n application/json json;\n image/webp webp;\n font/woff2 woff2;\n }\n }\n\n", anubisStaticPrefix, nginxPowStaticDirPlaceholder)
}
const anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/"
const anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) {
candidates := make([]uint, 0, len(certIDs)+1)
if primaryCertID != nil && *primaryCertID != 0 {
candidates = append(candidates, *primaryCertID)
}
candidates = append(candidates, certIDs...)
normalized := make([]uint, 0, len(candidates))
seen := make(map[uint]struct{}, len(candidates))
for _, certID := range candidates {
if certID == 0 {
continue
}
if _, ok := seen[certID]; ok {
continue
}
seen[certID] = struct{}{}
normalized = append(normalized, certID)
}
var normalizedPrimary *uint
if len(normalized) > 0 {
normalizedPrimary = &normalized[0]
}
return normalized, normalizedPrimary, nil
}
func normalizeSnapshotDomainCertificateIDs(
domains []string,
certIDs []uint,
domainCertIDs []uint,
) ([]uint, error) {
if len(domainCertIDs) > 0 {
if len(domains) > 0 && len(domainCertIDs) != len(domains) {
return nil, errors.New("snapshot domain_cert_ids length is invalid")
}
normalized := make([]uint, len(domainCertIDs))
copy(normalized, domainCertIDs)
return normalized, nil
}
if len(certIDs) == 0 {
return []uint{}, nil
}
if len(certIDs) == 1 {
normalized := make([]uint, len(domains))
for index := range normalized {
normalized[index] = certIDs[0]
}
return normalized, nil
}
if len(certIDs) == len(domains) {
normalized := make([]uint, len(certIDs))
copy(normalized, certIDs)
return normalized, nil
}
return []uint{}, nil
}
func uintPointerEqual(left *uint, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
@@ -803,6 +1174,18 @@ func uintPointerEqual(left *uint, right *uint) bool {
return *left == *right
}
func uintSliceEqual(left []uint, right []uint) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func checksum(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
@@ -836,18 +1219,91 @@ func nextVersionNumber(now time.Time) (string, error) {
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
}
func renderHTTPProxyServer(domain string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
func renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPRedirectServer(domain string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", domain)
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)
}
func renderHTTPSServer(domain string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
func renderHTTPSServer(serverNames string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s }\n}\n\n", domain, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPSServerWithCertificates(serverNames string, originURL string, originHost string, certificateIDs []uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
var certificateBlock strings.Builder
for _, certificateID := range certificateIDs {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
certificateBlock.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", certPath))
certificateBlock.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", keyPath))
}
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n%s\n location / {\n%s%s%s%s }\n}\n\n", serverNames, certificateBlock.String(), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
}
func renderServerNames(domains []string) string {
return strings.Join(domains, " ")
}
func validateCertificateCoverage(certificate *model.TLSCertificate, domains []string) error {
if certificate == nil {
return errors.New("certificate is nil")
}
leaf, err := parseLeafCertificate(certificate.CertPEM)
if err != nil {
return err
}
for _, domain := range domains {
if err := leaf.VerifyHostname(domain); err != nil {
return fmt.Errorf("certificate does not cover domain %s", domain)
}
}
return nil
}
func validateCertificateCoverageSet(certificates []*model.TLSCertificate, domains []string) error {
if len(certificates) == 0 {
return errors.New("certificate set is empty")
}
leaves := make([]interface{ VerifyHostname(string) error }, 0, len(certificates))
for _, certificate := range certificates {
if certificate == nil {
return errors.New("certificate is nil")
}
leaf, err := parseLeafCertificate(certificate.CertPEM)
if err != nil {
return err
}
leaves = append(leaves, leaf)
}
for _, domain := range domains {
covered := false
for _, leaf := range leaves {
if leaf.VerifyHostname(domain) == nil {
covered = true
break
}
}
if !covered {
return fmt.Errorf("certificate does not cover domain %s", domain)
}
}
return nil
}
func loadTLSCertificates(certIDs []uint) ([]*model.TLSCertificate, error) {
certificates := make([]*model.TLSCertificate, 0, len(certIDs))
for _, certID := range certIDs {
certificate, err := model.GetTLSCertificateByID(certID)
if err != nil {
return nil, err
}
certificates = append(certificates, certificate)
}
return certificates, nil
}
func renderConnectionUpgradeMap() string {
@@ -855,7 +1311,22 @@ func renderConnectionUpgradeMap() string {
}
func renderDefaultServerBlock() string {
return " server {\n listen 80 default_server;\n server_name _;\n\n return 404;\n }\n\n"
return strings.Join([]string{
" server {",
" listen 80 default_server;",
" server_name _;",
"",
" return 404;",
" }",
"",
" server {",
" listen 443 ssl default_server;",
" server_name _;",
"",
" ssl_reject_handshake on;",
" }",
"",
}, "\n")
}
func renderProxyHeaderBlock(originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, upstreamConfig routeUpstreamConfig) string {
@@ -906,6 +1377,23 @@ func renderRouteCacheBlock(cacheConfig routeCacheConfig, cfg openRestyConfigSnap
return builder.String()
}
func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
if limitConfig.LimitConnPerServer <= 0 && limitConfig.LimitConnPerIP <= 0 && strings.TrimSpace(limitConfig.LimitRate) == "" {
return ""
}
var builder strings.Builder
if limitConfig.LimitConnPerServer > 0 {
builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_server %d;\n", limitConfig.LimitConnPerServer))
}
if limitConfig.LimitConnPerIP > 0 {
builder.WriteString(fmt.Sprintf(" limit_conn openflare_conn_per_ip %d;\n", limitConfig.LimitConnPerIP))
}
if strings.TrimSpace(limitConfig.LimitRate) != "" {
builder.WriteString(fmt.Sprintf(" limit_rate %s;\n", limitConfig.LimitRate))
}
return builder.String()
}
func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
switch cacheConfig.Policy {
case proxyRouteCachePolicySuffix:
@@ -1022,6 +1510,10 @@ func buildUpstreamProxyPassURI(parsed *url.URL) string {
}
func buildRouteUpstreamName(route *model.ProxyRoute) string {
identity := strings.TrimSpace(route.SiteName)
if identity == "" {
identity = route.Domain
}
sanitized := strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
@@ -1033,7 +1525,7 @@ func buildRouteUpstreamName(route *model.ProxyRoute) string {
default:
return '_'
}
}, route.Domain)
}, identity)
sanitized = strings.Trim(sanitized, "_")
if sanitized == "" {
sanitized = "backend"
@@ -1102,3 +1594,40 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile {
}
return result
}
func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) {
type domainEntry struct {
Domains []string `json:"domains"`
Enabled bool `json:"enabled"`
Config map[string]interface{} `json:"config"`
}
entries := make([]domainEntry, 0)
hasPow := false
for _, route := range routes {
if !route.PoWEnabled {
continue
}
hasPow = true
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return "", nil, err
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil {
return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain)
}
entries = append(entries, domainEntry{
Domains: domains,
Enabled: true,
Config: cfg,
})
}
if !hasPow {
return "{}", nil, nil
}
data, err := json.Marshal(entries)
if err != nil {
return "", nil, err
}
return string(data), nil, nil
}
+403 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"openflare/common"
@@ -110,11 +111,34 @@ func TestCreateProxyRouteRejectsHTTPSWithoutCertificate(t *testing.T) {
Enabled: true,
EnableHTTPS: true,
})
if err == nil || !strings.Contains(err.Error(), "必须选择证书") {
if err == nil || !strings.Contains(err.Error(), "must select a certificate") {
t.Fatalf("expected certificate validation error, got %v", err)
}
}
func TestCreateProxyRouteSupportsWebsiteDomains(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "main-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.SiteName != "main-site" {
t.Fatalf("unexpected site name: %s", route.SiteName)
}
if route.Domain != "app.example.com" {
t.Fatalf("expected primary domain mirror, got %s", route.Domain)
}
if len(route.Domains) != 2 || route.Domains[1] != "www.example.com" {
t.Fatalf("expected domains payload to contain alias, got %#v", route.Domains)
}
}
func TestPublishConfigVersionRendersCustomHeaders(t *testing.T) {
setupServiceTestDB(t)
if err := model.UpdateOption("OpenRestyWebsocketEnabled", "true"); err != nil {
@@ -180,7 +204,7 @@ func TestCreateProxyRouteRejectsCachePolicyWithoutRules(t *testing.T) {
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicySuffix,
})
if err == nil || !strings.Contains(err.Error(), "至少填写一个后缀") {
if err == nil || !strings.Contains(err.Error(), "at least one suffix") {
t.Fatalf("expected cache rule validation error, got %v", err)
}
}
@@ -300,6 +324,284 @@ func TestPublishConfigVersionRendersMultipleUpstreams(t *testing.T) {
}
}
func TestPublishConfigVersionRendersMultiDomainWebsite(t *testing.T) {
setupServiceTestDB(t)
certPEM, keyPEM := generateCertificatePair(t, []string{"app.example.com", "www.example.com"})
certificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "multi-domain",
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate failed: %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
SiteName: "marketing-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
CertID: &certificate.ID,
RedirectHTTP: true,
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicyPathPrefix,
CacheRules: []string{"/assets"},
CustomHeaders: []ProxyRouteCustomHeaderInput{{Key: "X-Site", Value: "marketing"}},
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.RenderedConfig, "server_name app.example.com www.example.com;") {
t.Fatal("expected rendered config to include all domains in one server_name")
}
if strings.Contains(result.Version.RenderedConfig, "server_name app.example.com;") {
t.Fatal("expected rendered config to avoid standalone primary-domain server block")
}
if strings.Contains(result.Version.RenderedConfig, "server_name www.example.com;") {
t.Fatal("expected rendered config to avoid standalone alias server block")
}
if !strings.Contains(result.Version.SnapshotJSON, `"site_name":"marketing-site"`) {
t.Fatal("expected snapshot to include site_name")
}
if !strings.Contains(result.Version.SnapshotJSON, `"domains":["app.example.com","www.example.com"]`) {
t.Fatal("expected snapshot to include domain list")
}
}
func TestPublishConfigVersionRendersMultipleCertificatesForMultiDomainWebsite(t *testing.T) {
setupServiceTestDB(t)
appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"app.example.com"})
appCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "app-only",
CertPEM: appCertPEM,
KeyPEM: appKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate app-only failed: %v", err)
}
wwwCertPEM, wwwKeyPEM := generateCertificatePair(t, []string{"www.example.com"})
wwwCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "www-only",
CertPEM: wwwCertPEM,
KeyPEM: wwwKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate www-only failed: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "marketing-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
DomainCertIDs: []uint{appCertificate.ID, wwwCertificate.ID},
RedirectHTTP: true,
CacheEnabled: true,
CachePolicy: proxyRouteCachePolicyPathPrefix,
CacheRules: []string{"/assets"},
CustomHeaders: []ProxyRouteCustomHeaderInput{{Key: "X-Site", Value: "marketing"}},
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if route.CertID == nil || *route.CertID != appCertificate.ID {
t.Fatalf("expected primary cert mirror to point at first certificate, got %#v", route.CertID)
}
if len(route.CertIDs) != 2 || route.CertIDs[0] != appCertificate.ID || route.CertIDs[1] != wwwCertificate.ID {
t.Fatalf("expected cert_ids to persist in order, got %#v", route.CertIDs)
}
if len(route.DomainCertIDs) != 2 || route.DomainCertIDs[0] != appCertificate.ID || route.DomainCertIDs[1] != wwwCertificate.ID {
t.Fatalf("expected domain_cert_ids to persist per domain, got %#v", route.DomainCertIDs)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if strings.Count(result.Version.RenderedConfig, "ssl_certificate __OPENFLARE_CERT_DIR__/") != 2 {
t.Fatalf("expected rendered config to include two ssl_certificate directives, got %s", result.Version.RenderedConfig)
}
if strings.Count(result.Version.RenderedConfig, "ssl_certificate_key __OPENFLARE_CERT_DIR__/") != 2 {
t.Fatalf("expected rendered config to include two ssl_certificate_key directives, got %s", result.Version.RenderedConfig)
}
if !strings.Contains(result.Version.SupportFilesJSON, certificateCertFileName(appCertificate.ID)) {
t.Fatal("expected support files to include first certificate")
}
if !strings.Contains(result.Version.SupportFilesJSON, certificateCertFileName(wwwCertificate.ID)) {
t.Fatal("expected support files to include second certificate")
}
if !strings.Contains(result.Version.SnapshotJSON, `"cert_ids":[`) {
t.Fatal("expected snapshot to include cert_ids")
}
if !strings.Contains(result.Version.SnapshotJSON, `"domain_cert_ids":[`) {
t.Fatal("expected snapshot to include domain_cert_ids")
}
}
func TestPublishConfigVersionSkipsHTTPSForDomainsWithoutCertificate(t *testing.T) {
setupServiceTestDB(t)
appCertPEM, appKeyPEM := generateCertificatePair(t, []string{"app.example.com"})
appCertificate, err := CreateTLSCertificate(TLSCertificateInput{
Name: "app-only",
CertPEM: appCertPEM,
KeyPEM: appKeyPEM,
})
if err != nil {
t.Fatalf("CreateTLSCertificate app-only failed: %v", err)
}
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "partial-https-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
EnableHTTPS: true,
DomainCertIDs: []uint{appCertificate.ID, 0},
RedirectHTTP: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if len(route.CertIDs) != 1 || route.CertIDs[0] != appCertificate.ID {
t.Fatalf("expected website cert_ids to keep used certificates only, got %#v", route.CertIDs)
}
if len(route.DomainCertIDs) != 2 || route.DomainCertIDs[0] != appCertificate.ID || route.DomainCertIDs[1] != 0 {
t.Fatalf("expected domain_cert_ids to preserve unassigned domains, got %#v", route.DomainCertIDs)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;\n http2 on;\n server_name app.example.com www.example.com;") {
t.Fatal("expected https server block to exclude domains without certificate")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 443 ssl;\n http2 on;\n server_name app.example.com;") {
t.Fatal("expected https server block to contain only the certified domain")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 80;\n server_name app.example.com;\n\n return 301 https://$host$request_uri;") {
t.Fatal("expected certified domain to keep http redirect")
}
if !strings.Contains(result.Version.RenderedConfig, "listen 80;\n server_name www.example.com;") {
t.Fatal("expected non-certified domain to stay on plain http")
}
}
func TestDiffConfigVersionTracksAddedDomainWithinWebsite(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "main-site",
Domains: []string{"app.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if _, err := UpdateProxyRoute(route.ID, ProxyRouteInput{
SiteName: "main-site",
Domains: []string{"app.example.com", "www.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
}); err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
}
diff, err := DiffConfigVersion()
if err != nil {
t.Fatalf("DiffConfigVersion failed: %v", err)
}
if len(diff.AddedDomains) != 1 || diff.AddedDomains[0] != "www.example.com" {
t.Fatalf("unexpected added domains: %#v", diff.AddedDomains)
}
if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "app.example.com" {
t.Fatalf("unexpected modified domains: %#v", diff.ModifiedDomains)
}
if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "main-site" {
t.Fatalf("unexpected modified sites: %#v", diff.ModifiedSites)
}
}
func TestCreateProxyRouteRejectsInvalidRateLimitFields(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "limit.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
LimitConnPerServer: -1,
})
if err == nil || !strings.Contains(err.Error(), "limit_conn_per_server") {
t.Fatalf("expected limit_conn_per_server validation error, got %v", err)
}
_, err = CreateProxyRoute(ProxyRouteInput{
Domain: "limit.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
LimitRate: "12x",
})
if err == nil || !strings.Contains(err.Error(), "limit_rate") {
t.Fatalf("expected limit_rate validation error, got %v", err)
}
}
func TestPublishConfigVersionRendersRouteRateLimits(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
SiteName: "limited-site",
Domains: []string{"limit.example.com", "www.limit.example.com"},
OriginURL: "https://origin.internal",
Enabled: true,
LimitConnPerServer: 120,
LimitConnPerIP: 12,
LimitRate: "512K",
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
result, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
if !strings.Contains(result.Version.MainConfig, "limit_conn_zone $server_name zone=openflare_conn_per_server:10m;") {
t.Fatal("expected main config to include server limit_conn_zone")
}
if !strings.Contains(result.Version.MainConfig, "limit_conn_zone $binary_remote_addr zone=openflare_conn_per_ip:10m;") {
t.Fatal("expected main config to include ip limit_conn_zone")
}
if !strings.Contains(result.Version.RenderedConfig, "limit_conn openflare_conn_per_server 120;") {
t.Fatal("expected rendered config to include per-server limit_conn")
}
if !strings.Contains(result.Version.RenderedConfig, "limit_conn openflare_conn_per_ip 12;") {
t.Fatal("expected rendered config to include per-ip limit_conn")
}
if !strings.Contains(result.Version.RenderedConfig, "limit_rate 512k;") {
t.Fatal("expected rendered config to include normalized limit_rate")
}
if !strings.Contains(result.Version.SnapshotJSON, `"limit_rate":"512k"`) {
t.Fatal("expected snapshot to include normalized limit_rate")
}
}
func TestPublishConfigVersionRendersHostnameLoadBalancingUpstream(t *testing.T) {
setupServiceTestDB(t)
@@ -622,6 +924,93 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
}
}
func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
firstRelease, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("initial PublishConfigVersion failed: %v", err)
}
if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"pow_config.json"`) {
t.Fatal("expected publish to include pow_config.json support file")
}
_, err = UpdateProxyRoute(route.ID, ProxyRouteInput{
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":5,"algorithm":"slow","session_ttl":7200,"challenge_ttl":180,"whitelist":{"ips":["127.0.0.1"],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":["/login"],"path_regexes":[],"user_agents":[]}}`,
RedirectHTTP: false,
})
if err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
}
diff, err := DiffConfigVersion()
if err != nil {
t.Fatalf("DiffConfigVersion failed: %v", err)
}
if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark domain as modified, got %#v", diff.ModifiedDomains)
}
if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark site as modified, got %#v", diff.ModifiedSites)
}
secondRelease, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion after PoW change failed: %v", err)
}
if firstRelease.Version.Checksum == secondRelease.Version.Checksum {
t.Fatal("expected PoW change to alter published checksum")
}
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"pow_enabled":true`) {
t.Fatal("expected snapshot to persist PoW enabled state")
}
if !strings.Contains(secondRelease.Version.MainConfig, "lua_shared_dict openflare_pow_config 1m;") {
t.Fatal("expected main config to declare shared dict for pow config")
}
if !strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/ {") {
t.Fatal("expected rendered config to expose anubis static location")
}
if strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/static/ {") {
t.Fatal("expected rendered config to avoid duplicate static path segment")
}
if !strings.Contains(secondRelease.Version.RenderedConfig, "application/javascript js mjs;") {
t.Fatal("expected rendered config to serve Anubis module scripts with a JavaScript MIME type")
}
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"difficulty":5`) {
t.Fatal("expected snapshot to persist PoW config")
}
var supportFiles []SupportFile
if err := json.Unmarshal([]byte(secondRelease.Version.SupportFilesJSON), &supportFiles); err != nil {
t.Fatalf("failed to decode support files: %v", err)
}
foundPowSupportFile := false
for _, file := range supportFiles {
if file.Path != "pow_config.json" {
continue
}
foundPowSupportFile = true
if !strings.Contains(file.Content, `"difficulty":5`) {
t.Fatalf("expected pow support file to persist config, got %s", file.Content)
}
}
if !foundPowSupportFile {
t.Fatal("expected publish to include pow_config.json support file")
}
}
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
setupServiceTestDB(t)
@@ -648,12 +1037,18 @@ func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
t.Fatal("expected preview main config to include default http server")
}
if !strings.Contains(preview.MainConfig, "listen 443 ssl default_server;") {
t.Fatal("expected preview main config to include default https server")
}
if !strings.Contains(preview.MainConfig, "server_name _;") {
t.Fatal("expected preview main config to include default server_name")
}
if !strings.Contains(preview.MainConfig, "return 404;") {
t.Fatal("expected preview main config to return 404 for unmatched hosts")
}
if !strings.Contains(preview.MainConfig, "ssl_reject_handshake on;") {
t.Fatal("expected preview main config to reject unmatched https handshakes")
}
}
func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) {
@@ -706,6 +1101,12 @@ func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
if !strings.Contains(preview.MainConfig, "listen 80 default_server;") {
t.Fatal("expected preview main config to preserve managed default server block")
}
if !strings.Contains(preview.MainConfig, "listen 443 ssl default_server;") {
t.Fatal("expected preview main config to preserve managed default https server block")
}
if !strings.Contains(preview.MainConfig, "ssl_reject_handshake on;") {
t.Fatal("expected preview main config to preserve managed https handshake rejection")
}
invalidTemplate := strings.ReplaceAll(
common.OpenRestyMainConfigTemplate,
@@ -11,6 +11,9 @@ const (
func renderOpenRestyObservabilityTemplateBlock() string {
return stringsJoinLines(
" lua_shared_dict openflare_observability 10m;",
" lua_shared_dict openflare_pow_config 1m;",
" lua_shared_dict openflare_pow_challenges 10m;",
" lua_shared_dict openflare_pow_sessions 20m;",
fmt.Sprintf(" init_worker_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityInitLuaPath),
fmt.Sprintf(" log_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityLogLuaPath),
"",
File diff suppressed because it is too large Load Diff
+38 -10
View File
@@ -2,6 +2,7 @@ package service
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
@@ -54,7 +55,7 @@ func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, err
}
if err = certificate.Insert(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("证书名称已存在")
return nil, errors.New("certificate name already exists")
}
return nil, err
}
@@ -63,7 +64,7 @@ func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, err
func CreateTLSCertificateFromFiles(name string, certFile *multipart.FileHeader, keyFile *multipart.FileHeader, remark string) (*model.TLSCertificate, error) {
if certFile == nil || keyFile == nil {
return nil, errors.New("证书文件和私钥文件不能为空")
return nil, errors.New("certificate file and key file cannot be empty")
}
certContent, err := readMultipartFile(certFile)
if err != nil {
@@ -101,13 +102,40 @@ func UpdateTLSCertificate(id uint, input TLSCertificateInput) (*model.TLSCertifi
}
func DeleteTLSCertificate(id uint) error {
var routeCount int64
if err := model.DB.Model(&model.ProxyRoute{}).Where("cert_id = ?", id).Count(&routeCount).Error; err != nil {
routes, err := model.ListProxyRoutes()
if err != nil {
return err
}
if routeCount > 0 {
return errors.New("证书仍被反代规则引用,无法删除")
for _, route := range routes {
if route == nil {
continue
}
if route.CertID != nil && *route.CertID == id {
return errors.New("certificate is still referenced by proxy routes")
}
if strings.TrimSpace(route.CertIDs) == "" {
continue
}
var certIDs []uint
if err := json.Unmarshal([]byte(route.CertIDs), &certIDs); err != nil {
return fmt.Errorf("proxy route %d cert_ids payload is invalid: %w", route.ID, err)
}
for _, certID := range certIDs {
if certID == id {
return errors.New("certificate is still referenced by proxy routes")
}
}
domainCertIDs, err := decodeStoredDomainCertIDs(route.DomainCertIDs, 0)
if err != nil {
return fmt.Errorf("proxy route %d domain_cert_ids payload is invalid: %w", route.ID, err)
}
for _, certID := range domainCertIDs {
if certID == id {
return errors.New("certificate is still referenced by proxy routes")
}
}
}
certificate, err := model.GetTLSCertificateByID(id)
if err != nil {
return err
@@ -121,17 +149,17 @@ func buildTLSCertificate(existing *model.TLSCertificate, input TLSCertificateInp
keyPEM := strings.TrimSpace(input.KeyPEM)
remark := strings.TrimSpace(input.Remark)
if name == "" {
return nil, errors.New("证书名称不能为空")
return nil, errors.New("certificate name cannot be empty")
}
if certPEM == "" || keyPEM == "" {
return nil, errors.New("证书内容和私钥内容不能为空")
return nil, errors.New("certificate content and key content cannot be empty")
}
parsed, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
if err != nil {
return nil, fmt.Errorf("证书或私钥格式不合法: %w", err)
return nil, fmt.Errorf("certificate or key format is invalid: %w", err)
}
if len(parsed.Certificate) == 0 {
return nil, errors.New("证书内容不合法")
return nil, errors.New("certificate content is invalid")
}
leaf, err := parseLeafCertificate(certPEM)
if err != nil {
@@ -0,0 +1,16 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { ProxyRouteConfigPage } from '@/features/proxy-routes/components/proxy-route-config-page';
export default function ProxyRouteDetailRoute() {
const searchParams = useSearchParams();
return (
<ProxyRouteConfigPage
routeId={searchParams.get('id') ?? ''}
initialSection={searchParams.get('section') ?? 'domains'}
/>
);
}
+11 -11
View File
@@ -5,18 +5,18 @@ import type { ReactNode } from 'react';
import { AppProviders } from '@/components/providers/app-providers';
import { getThemeInitScript } from '@/lib/theme/theme';
import './globals.css';
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
import './globals.css';
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: {
default: 'OpenFlare 控制台',
template: '%s | OpenFlare',
},
description: 'OpenFlare 管理端新版工程骨架',
description: 'OpenFlare 管理端',
applicationName: 'OpenFlare',
};
@@ -24,8 +24,8 @@ interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
<body>
<Script id='theme-init' strategy='beforeInteractive'>
@@ -34,5 +34,5 @@ export default function RootLayout({ children }: RootLayoutProps) {
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
);
}
@@ -2,7 +2,7 @@
import { useMutation } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
@@ -13,9 +13,11 @@ import { exchangeGitHubCode } from '@/features/auth/api/auth';
export function GitHubOAuthCallback() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const { setUser } = useAuth();
const handledCodeRef = useRef<string | null>(null);
const [prompt, setPrompt] = useState('正在处理 GitHub 授权结果...');
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
const [message, setMessage] = useState<{ tone: 'danger' | 'success'; text: string } | null>(null);
const code = searchParams?.get('code')?.trim() ?? '';
const mutation = useMutation({
mutationFn: exchangeGitHubCode,
@@ -31,15 +33,19 @@ export function GitHubOAuthCallback() {
});
useEffect(() => {
const code = searchParams?.get('code');
if (!code) {
if (!code) {
setPrompt('缺少授权 code');
setMessage({ tone: 'danger', text: '未收到 GitHub 授权参数,请返回登录页重试。' });
return;
}
mutation.mutate(code);
}, [mutation, searchParams]);
if (handledCodeRef.current === code) {
return;
}
handledCodeRef.current = code;
mutation.mutate(code);
}, [code, mutation]);
return (
<AppCard title='GitHub OAuth 回调' description={prompt}>
@@ -1,128 +1,170 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card';
import { login } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const loginSchema = z.object({
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
setUser(user);
router.replace(redirect);
},
onError: (error: Error) => {
setErrorMessage(error.message || '登录失败,请稍后重试。');
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
loginMutation.mutate(values);
});
const handleGitHubLogin = () => {
const clientId = statusQuery.data?.github_client_id;
if (!clientId) {
setErrorMessage('GitHub 登录当前不可用。');
return;
}
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('scope', 'user:email');
window.location.href = authorizeUrl.toString();
};
return (
<PublicAuthGuard>
<AppCard title='用户登录'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='密码'>
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} />
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? <InlineMessage tone='danger' message={errorMessage} /> : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='submit' disabled={loginMutation.isPending}>
{loginMutation.isPending ? '登录中...' : '登录'}
</AuthButton>
{statusQuery.data?.github_oauth ? (
<SecondaryButton type='button' onClick={handleGitHubLogin} className='w-full sm:w-auto'>
GitHub 登录
</SecondaryButton>
) : null}
</div>
</form>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link href='/reset' className='text-[var(--brand-primary)] transition hover:opacity-80'>
忘记密码?
</Link>
<span>·</span>
<Link href='/register' className='text-[var(--brand-primary)] transition hover:opacity-80'>
注册
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { useAuth } from '@/components/providers/auth-provider';
import { AppCard } from '@/components/ui/app-card';
import { login } from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const TEXT = {
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
loginFailed: '\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
githubUnavailable: 'GitHub \u767b\u5f55\u5f53\u524d\u4e0d\u53ef\u7528\u3002',
title: '\u7528\u6237\u767b\u5f55',
username: '\u7528\u6237\u540d',
password: '\u5bc6\u7801',
loginPending: '\u767b\u5f55\u4e2d...',
login: '\u767b\u5f55',
githubLogin: 'GitHub \u767b\u5f55',
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
register: '\u6ce8\u518c',
};
const loginSchema = z.object({
username: z.string().min(1, TEXT.usernameRequired),
password: z.string().min(1, TEXT.passwordRequired),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const { setUser } = useAuth();
const [errorMessage, setErrorMessage] = useState('');
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: '',
password: '',
},
});
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const canUsePasswordRegister =
(statusQuery.data?.register_enabled ?? false) &&
(statusQuery.data?.password_register_enabled ?? false);
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
setUser(user);
router.replace(redirect);
},
onError: (error: Error) => {
setErrorMessage(error.message || TEXT.loginFailed);
},
});
const handleSubmit = form.handleSubmit((values) => {
setErrorMessage('');
loginMutation.mutate(values);
});
const handleGitHubLogin = () => {
const clientId = statusQuery.data?.github_client_id;
if (!clientId) {
setErrorMessage(TEXT.githubUnavailable);
return;
}
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('scope', 'user:email');
window.location.href = authorizeUrl.toString();
};
return (
<PublicAuthGuard>
<AppCard title={TEXT.title}>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username}>
<AuthInput
placeholder={TEXT.username}
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.password}>
<AuthInput
type='password'
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
{errorMessage ? (
<InlineMessage tone='danger' message={errorMessage} />
) : null}
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthButton type='submit' disabled={loginMutation.isPending}>
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
</AuthButton>
{statusQuery.data?.github_oauth ? (
<SecondaryButton
type='button'
onClick={handleGitHubLogin}
className='w-full sm:w-auto'
>
{TEXT.githubLogin}
</SecondaryButton>
) : null}
</div>
</form>
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
<Link
href='/reset'
className='text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.forgotPassword}
</Link>
{canUsePasswordRegister ? (
<>
<span>|</span>
<Link
href='/register'
className='text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.register}
</Link>
</>
) : null}
</div>
</AppCard>
</PublicAuthGuard>
);
}
@@ -1,225 +1,332 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import {
register as registerUser,
sendEmailVerification,
} from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const baseSchemaObject = z.object({
username: z.string().min(1, '请输入用户名').max(12, '用户名最长 12 位'),
password: z.string().min(8, '密码至少 8 位').max(20, '密码最长 20 位'),
password2: z.string().min(8, '请再次输入密码'),
email: z.string().optional(),
verification_code: z.string().optional(),
});
const baseSchema = baseSchemaObject.refine((data) => data.password === data.password2, {
message: '两次输入的密码不一致',
path: ['password2'],
});
type RegisterFormValues = z.infer<typeof baseSchema>;
export function RegisterForm() {
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{ tone: 'success' | 'danger' | 'info'; text: string } | null>(null);
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const schema = useMemo(() => {
if (!needsEmailVerification) {
return baseSchema;
}
return baseSchemaObject
.extend({
email: z.string().email('请输入有效邮箱地址'),
verification_code: z.string().min(1, '请输入验证码'),
})
.refine((data) => data.password === data.password2, {
message: '两次输入的密码不一致',
path: ['password2'],
});
}, [needsEmailVerification]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
},
});
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) =>
registerUser(
{
username: values.username,
password: values.password,
email: values.email,
verification_code: values.verification_code,
},
turnstileToken || undefined,
),
onSuccess: () => {
router.replace('/login');
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '注册失败,请稍后重试。' });
},
});
const verificationMutation = useMutation({
mutationFn: async () => {
const email = form.getValues('email');
if (!email) {
form.setError('email', { message: '请输入邮箱地址' });
return;
}
await sendEmailVerification(email, turnstileToken || undefined);
},
onSuccess: () => {
setMessage({ tone: 'success', text: '验证码发送成功,请检查邮箱。' });
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || '验证码发送失败,请稍后重试。' });
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
registerMutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title='新用户注册' description='兼容现有密码注册链路,后续可继续扩展第三方注册。'>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label='用户名' hint='最长 12 位'>
<AuthInput placeholder='请输入用户名' {...form.register('username')} />
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='密码' hint='最短 8 位,最长 20 位'>
<AuthInput type='password' placeholder='请输入密码' {...form.register('password')} />
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='确认密码'>
<AuthInput type='password' placeholder='请再次输入密码' {...form.register('password2')} />
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label='邮箱地址'>
<AuthInput type='email' placeholder='请输入邮箱地址' {...form.register('email')} />
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label='邮箱验证码'>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder='请输入验证码'
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: '请先完成人机验证。' });
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending ? '发送中...' : '获取验证码'}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? <InlineMessage tone={message.tone} message={message.text} /> : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? '注册中...' : '注册'}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
已有账户?
<Link href='/login' className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'>
点击登录
</Link>
</div>
</AppCard>
</PublicAuthGuard>
);
}
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { InlineMessage } from '@/components/feedback/inline-message';
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
import { AppCard } from '@/components/ui/app-card';
import {
register as registerUser,
sendEmailVerification,
} from '@/features/auth/api/auth';
import { getPublicStatus } from '@/features/auth/api/public';
import {
AuthButton,
AuthFormField,
AuthInput,
SecondaryButton,
} from '@/features/auth/components/auth-form-primitives';
import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
const TEXT = {
title: '\u65b0\u7528\u6237\u6ce8\u518c',
description:
'\u517c\u5bb9\u73b0\u6709\u5bc6\u7801\u6ce8\u518c\u94fe\u8def\uff0c\u540e\u7eed\u53ef\u7ee7\u7eed\u6269\u5c55\u7b2c\u4e09\u65b9\u6ce8\u518c\u3002',
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
usernameTooLong: '\u7528\u6237\u540d\u6700\u957f 12 \u4f4d',
passwordTooShort: '\u5bc6\u7801\u81f3\u5c11 8 \u4f4d',
passwordTooLong: '\u5bc6\u7801\u6700\u957f 20 \u4f4d',
passwordRepeatRequired: '\u8bf7\u518d\u6b21\u8f93\u5165\u5bc6\u7801',
passwordMismatch: '\u4e24\u6b21\u8f93\u5165\u7684\u5bc6\u7801\u4e0d\u4e00\u81f4',
emailInvalid: '\u8bf7\u8f93\u5165\u6709\u6548\u90ae\u7bb1\u5730\u5740',
codeRequired: '\u8bf7\u8f93\u5165\u9a8c\u8bc1\u7801',
registerFailed: '\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
emailRequired: '\u8bf7\u8f93\u5165\u90ae\u7bb1\u5730\u5740',
verificationSent:
'\u9a8c\u8bc1\u7801\u53d1\u9001\u6210\u529f\uff0c\u8bf7\u68c0\u67e5\u90ae\u7bb1\u3002',
verificationFailed:
'\u9a8c\u8bc1\u7801\u53d1\u9001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
turnstileRequired: '\u8bf7\u5148\u5b8c\u6210\u4eba\u673a\u9a8c\u8bc1\u3002',
registerClosed:
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u65b0\u7528\u6237\u6ce8\u518c\u3002',
passwordRegisterClosed:
'\u7ba1\u7406\u5458\u5df2\u5173\u95ed\u5bc6\u7801\u6ce8\u518c\uff0c\u8bf7\u4f7f\u7528\u7b2c\u4e09\u65b9\u767b\u5f55\u5165\u53e3\u5b8c\u6210\u6ce8\u518c\u3002',
hasAccount: '\u5df2\u6709\u8d26\u53f7\uff1f',
backToLogin:
'\u8fd4\u56de\u767b\u5f55\u9875\u67e5\u770b\u53ef\u7528\u5165\u53e3\uff1a',
clickLogin: '\u70b9\u51fb\u767b\u5f55',
username: '\u7528\u6237\u540d',
usernameHint: '\u6700\u957f 12 \u4f4d',
password: '\u5bc6\u7801',
passwordHint: '\u6700\u77ed 8 \u4f4d\uff0c\u6700\u957f 20 \u4f4d',
passwordConfirm: '\u786e\u8ba4\u5bc6\u7801',
email: '\u90ae\u7bb1\u5730\u5740',
emailCode: '\u90ae\u7bb1\u9a8c\u8bc1\u7801',
getCode: '\u83b7\u53d6\u9a8c\u8bc1\u7801',
gettingCode: '\u53d1\u9001\u4e2d...',
register: '\u6ce8\u518c',
registering: '\u6ce8\u518c\u4e2d...',
};
const baseSchemaObject = z.object({
username: z.string().min(1, TEXT.usernameRequired).max(12, TEXT.usernameTooLong),
password: z.string().min(8, TEXT.passwordTooShort).max(20, TEXT.passwordTooLong),
password2: z.string().min(8, TEXT.passwordRepeatRequired),
email: z.string().optional(),
verification_code: z.string().optional(),
});
const baseSchema = baseSchemaObject.refine(
(data) => data.password === data.password2,
{
message: TEXT.passwordMismatch,
path: ['password2'],
},
);
type RegisterFormValues = z.infer<typeof baseSchema>;
export function RegisterForm() {
const router = useRouter();
const [turnstileToken, setTurnstileToken] = useState('');
const [message, setMessage] = useState<{
tone: 'success' | 'danger' | 'info';
text: string;
} | null>(null);
const statusQuery = useQuery({
queryKey: ['public-status'],
queryFn: getPublicStatus,
});
const needsEmailVerification = statusQuery.data?.email_verification ?? false;
const needsTurnstile = statusQuery.data?.turnstile_check ?? false;
const registerEnabled = statusQuery.data?.register_enabled ?? false;
const passwordRegisterEnabled =
statusQuery.data?.password_register_enabled ?? false;
const schema = useMemo(() => {
if (!needsEmailVerification) {
return baseSchema;
}
return baseSchemaObject
.extend({
email: z.string().email(TEXT.emailInvalid),
verification_code: z.string().min(1, TEXT.codeRequired),
})
.refine((data) => data.password === data.password2, {
message: TEXT.passwordMismatch,
path: ['password2'],
});
}, [needsEmailVerification]);
const form = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
},
});
const registerMutation = useMutation({
mutationFn: (values: RegisterFormValues) =>
registerUser(
{
username: values.username,
password: values.password,
email: values.email,
verification_code: values.verification_code,
},
turnstileToken || undefined,
),
onSuccess: () => {
router.replace('/login');
},
onError: (error: Error) => {
setMessage({ tone: 'danger', text: error.message || TEXT.registerFailed });
},
});
const verificationMutation = useMutation({
mutationFn: async () => {
const email = form.getValues('email');
if (!email) {
form.setError('email', { message: TEXT.emailRequired });
return;
}
await sendEmailVerification(email, turnstileToken || undefined);
},
onSuccess: () => {
setMessage({ tone: 'success', text: TEXT.verificationSent });
},
onError: (error: Error) => {
setMessage({
tone: 'danger',
text: error.message || TEXT.verificationFailed,
});
},
});
const handleSubmit = form.handleSubmit((values) => {
setMessage(null);
if (needsTurnstile && !turnstileToken) {
setMessage({ tone: 'info', text: TEXT.turnstileRequired });
return;
}
registerMutation.mutate(values);
});
return (
<PublicAuthGuard>
<AppCard title={TEXT.title} description={TEXT.description}>
{!registerEnabled ? (
<div className='space-y-4'>
<InlineMessage tone='info' message={TEXT.registerClosed} />
<div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</div>
) : !passwordRegisterEnabled ? (
<div className='space-y-4'>
<InlineMessage tone='info' message={TEXT.passwordRegisterClosed} />
<div className='text-sm text-[var(--foreground-secondary)]'>
{TEXT.backToLogin}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</div>
) : (
<>
<form className='space-y-4' onSubmit={handleSubmit}>
<AuthFormField label={TEXT.username} hint={TEXT.usernameHint}>
<AuthInput
placeholder={TEXT.username}
{...form.register('username')}
/>
{form.formState.errors.username ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.username.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.password} hint={TEXT.passwordHint}>
<AuthInput
type='password'
placeholder={TEXT.password}
{...form.register('password')}
/>
{form.formState.errors.password ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.passwordConfirm}>
<AuthInput
type='password'
placeholder={TEXT.passwordConfirm}
{...form.register('password2')}
/>
{form.formState.errors.password2 ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.password2.message}
</span>
) : null}
</AuthFormField>
{needsEmailVerification ? (
<>
<AuthFormField label={TEXT.email}>
<AuthInput
type='email'
placeholder={TEXT.email}
{...form.register('email')}
/>
{form.formState.errors.email ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.email.message}
</span>
) : null}
</AuthFormField>
<AuthFormField label={TEXT.emailCode}>
<div className='flex flex-col gap-3 sm:flex-row'>
<AuthInput
placeholder={TEXT.emailCode}
className='flex-1'
{...form.register('verification_code')}
/>
<SecondaryButton
type='button'
onClick={() => {
if (needsTurnstile && !turnstileToken) {
setMessage({
tone: 'info',
text: TEXT.turnstileRequired,
});
return;
}
setMessage(null);
verificationMutation.mutate();
}}
disabled={verificationMutation.isPending}
>
{verificationMutation.isPending
? TEXT.gettingCode
: TEXT.getCode}
</SecondaryButton>
</div>
{form.formState.errors.verification_code ? (
<span className='text-xs text-[var(--status-danger-foreground)]'>
{form.formState.errors.verification_code.message}
</span>
) : null}
</AuthFormField>
</>
) : null}
{needsTurnstile && statusQuery.data?.turnstile_site_key ? (
<TurnstileWidget
siteKey={statusQuery.data.turnstile_site_key}
onVerify={(token) => setTurnstileToken(token)}
onExpire={() => setTurnstileToken('')}
onError={() => setTurnstileToken('')}
/>
) : null}
{message ? (
<InlineMessage tone={message.tone} message={message.text} />
) : null}
<AuthButton type='submit' disabled={registerMutation.isPending}>
{registerMutation.isPending ? TEXT.registering : TEXT.register}
</AuthButton>
</form>
<div className='mt-6 text-sm text-[var(--foreground-secondary)]'>
{TEXT.hasAccount}
<Link
href='/login'
className='ml-2 text-[var(--brand-primary)] transition hover:opacity-80'
>
{TEXT.clickLogin}
</Link>
</div>
</>
)}
</AppCard>
</PublicAuthGuard>
);
}
@@ -300,9 +300,6 @@ function PublishPreviewCard({
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
Pending Main Config
</p>
<p className="text-xs text-[var(--foreground-secondary)]">
{`Checksum: ${preview.checksum}`}
</p>
</div>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{preview.main_config}
@@ -27,16 +27,22 @@ export interface ConfigPreviewResult {
support_files: SupportFile[];
checksum: string;
route_count: number;
website_count: number;
}
export interface ConfigDiffResult {
active_version?: string;
added_sites: string[];
removed_sites: string[];
modified_sites: string[];
added_domains: string[];
removed_domains: string[];
modified_domains: string[];
main_config_changed: boolean;
changed_option_keys: string[];
changed_option_details: ConfigOptionDiffItem[];
current_website_count: number;
active_website_count: number;
}
export interface ConfigOptionDiffItem {
@@ -12,7 +12,7 @@ import {useAuth} from '@/components/providers/auth-provider';
import {AppCard} from '@/components/ui/app-card';
import {StatusBadge} from '@/components/ui/status-badge';
import {getConfigVersionPreview} from '@/features/config-versions/api/config-versions';
import {getOptions, updateOption} from '@/features/settings/api/settings';
import {getOptions, updateOptions} from '@/features/settings/api/settings';
import type {OptionItem} from '@/features/settings/types';
import {
CodeBlock,
@@ -277,9 +277,7 @@ export function PerformancePage() {
entries: Array<[string, string]>,
successMessage: string,
) => {
for (const [key, value] of entries) {
await updateOption(key, value);
}
await updateOptions(entries.map(([key, value]) => ({key, value})));
await Promise.all([
queryClient.invalidateQueries({queryKey: settingsQueryKey}),
@@ -7,12 +7,16 @@ import type {
TlsCertificateItem,
} from '@/features/proxy-routes/types';
export function getProxyRoutes() {
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
}
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
export function getProxyRoutes() {
return apiRequest<ProxyRouteItem[]>('/proxy-routes/');
}
export function getProxyRoute(id: number) {
return apiRequest<ProxyRouteItem>(`/proxy-routes/${id}`);
}
export function createProxyRoute(payload: ProxyRouteMutationPayload) {
return apiRequest<ProxyRouteItem>('/proxy-routes/', {
method: 'POST',
body: JSON.stringify(payload),
});
@@ -0,0 +1,268 @@
'use client';
import { useId, useMemo } from 'react';
import { Minus, Plus } from 'lucide-react';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import {
ResourceInput,
ResourceSelect,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
export type DomainListRow = {
domain: string;
certificateId: string;
};
const actionButtonBaseClassName = 'h-11 w-11 shrink-0 rounded-2xl px-0';
const removeButtonClassName =
'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--status-danger-border)] hover:bg-[var(--status-danger-soft)] hover:text-[var(--status-danger-foreground)] disabled:border-[var(--border-default)] disabled:bg-[var(--surface-muted)] disabled:text-[var(--foreground-muted)]';
const addButtonClassName =
'border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--brand-primary)] hover:bg-[var(--brand-primary-soft)] hover:text-[var(--brand-primary)]';
function ensureRows(rows: DomainListRow[]) {
return rows.length > 0 ? rows : [{ domain: '', certificateId: '' }];
}
function buildSuggestionSources(domains: string[]) {
const values = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase().replace(/^\*\./, '');
if (!normalized) {
continue;
}
values.add(normalized);
const segments = normalized.split('.');
for (let index = 1; index < segments.length - 1; index += 1) {
values.add(segments.slice(index).join('.'));
}
}
return Array.from(values);
}
function buildDomainSuggestions(
input: string,
sources: string[],
rows: DomainListRow[],
) {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return [];
}
const existingDomains = new Set(
rows
.map((row) => row.domain.trim().toLowerCase())
.filter((row) => row && row !== normalizedInput),
);
const suggestions: string[] = [];
for (const source of sources) {
if (source.startsWith(normalizedInput) && source !== normalizedInput) {
suggestions.push(source);
}
const separatorIndex = normalizedInput.lastIndexOf('.');
if (separatorIndex <= 0) {
continue;
}
const prefix = normalizedInput.slice(0, separatorIndex);
const suffixInput = normalizedInput.slice(separatorIndex + 1);
if (!suffixInput || source.startsWith(suffixInput)) {
suggestions.push(`${prefix}.${source}`);
}
}
return suggestions.filter((suggestion, index) => {
return (
suggestion !== normalizedInput &&
!existingDomains.has(suggestion) &&
suggestions.indexOf(suggestion) === index
);
});
}
export function buildDomainRowsFromRoute(
domains: string[],
domainCertIDs: number[],
certIDs: number[],
): DomainListRow[] {
if (domains.length === 0) {
return ensureRows([]);
}
if (domainCertIDs.length === domains.length) {
return domains.map((domain, index) => ({
domain,
certificateId: domainCertIDs[index]
? String(domainCertIDs[index])
: '',
}));
}
if (certIDs.length === 0) {
return domains.map((domain) => ({ domain, certificateId: '' }));
}
if (certIDs.length === 1) {
return domains.map((domain) => ({
domain,
certificateId: String(certIDs[0]),
}));
}
return domains.map((domain, index) => ({
domain,
certificateId: certIDs[index] ? String(certIDs[index]) : '',
}));
}
export function DomainListInput({
rows,
onChange,
onBlur,
suggestionSources = [],
certificates = [],
domainPlaceholder = 'app.example.com',
}: {
rows: DomainListRow[];
onChange: (rows: DomainListRow[]) => void;
onBlur?: () => void;
suggestionSources?: string[];
certificates?: TlsCertificateItem[];
domainPlaceholder?: string;
}) {
const listId = useId();
const safeRows = ensureRows(rows);
const normalizedSources = useMemo(
() => buildSuggestionSources(suggestionSources),
[suggestionSources],
);
const updateRows = (nextRows: DomainListRow[]) => {
onChange(ensureRows(nextRows));
};
return (
<div className="space-y-4">
{safeRows.map((row, index) => {
const suggestions = buildDomainSuggestions(
row.domain,
normalizedSources,
safeRows,
).slice(0, 4);
return (
<div key={`${index}-${safeRows.length}`} className="space-y-2">
<div className="grid gap-3 md:grid-cols-[44px_minmax(0,1fr)_280px] md:items-start">
<SecondaryButton
type="button"
aria-label={`删除域名输入框 ${index + 1}`}
className={`${actionButtonBaseClassName} ${removeButtonClassName}`}
disabled={safeRows.length === 1}
onClick={() => {
if (safeRows.length === 1) {
updateRows([{ domain: '', certificateId: '' }]);
return;
}
updateRows(
safeRows.filter((_, rowIndex) => rowIndex !== index),
);
}}
>
<Minus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
<div className="min-w-0 space-y-2">
<ResourceInput
value={row.domain}
list={`${listId}-${index}`}
aria-label={`域名 ${index + 1}`}
placeholder={index === 0 ? domainPlaceholder : 'www.example.com'}
onBlur={onBlur}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
/>
<datalist id={`${listId}-${index}`}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{suggestions.map((suggestion) => (
<button
key={suggestion}
type="button"
className="inline-flex items-center rounded-full border border-[var(--border-default)] bg-[var(--surface-panel)] px-3 py-1 text-xs text-[var(--foreground-secondary)] transition hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]"
onClick={() => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
domain: suggestion,
};
updateRows(nextRows);
}}
>
{suggestion}
</button>
))}
</div>
) : null}
</div>
<ResourceSelect
aria-label={`证书 ${index + 1}`}
value={row.certificateId}
onChange={(event) => {
const nextRows = safeRows.slice();
nextRows[index] = {
...nextRows[index],
certificateId: event.target.value,
};
updateRows(nextRows);
}}
className="h-12"
>
<option value="">
{certificates.length === 0 ? '暂无可选证书' : '选择证书'}
</option>
{certificates.map((certificate) => (
<option key={certificate.id} value={certificate.id}>
{certificate.name}
</option>
))}
</ResourceSelect>
</div>
</div>
);
})}
<SecondaryButton
type="button"
aria-label="新增域名输入框"
className={`${actionButtonBaseClassName} ${addButtonClassName}`}
onClick={() => {
updateRows([...safeRows, { domain: '', certificateId: '' }]);
}}
>
<Plus aria-hidden="true" className="h-[14px] w-[14px]" />
</SecondaryButton>
</div>
);
}
@@ -0,0 +1,316 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { Drawer } from '@/components/ui/drawer';
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
import { createProxyRoute } from '@/features/proxy-routes/api/proxy-routes';
import {
DomainListInput,
type DomainListRow,
} from '@/features/proxy-routes/components/domain-list-input';
import {
buildOriginUrl,
getErrorMessage,
parseOriginUrl,
parseOriginUrls,
validateDomains,
} from '@/features/proxy-routes/helpers';
import type { ProxyRouteItem } from '@/features/proxy-routes/types';
import { getTlsCertificates } from '@/features/tls-certificates/api/tls-certificates';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
ToggleField,
} from '@/features/shared/components/resource-primitives';
const domainRowSchema = z.object({
domain: z.string(),
certificateId: z.string(),
});
const createWebsiteSchema = z
.object({
site_name: z.string().trim().max(255, '站点标识不能超过 255 个字符'),
domain_rows: z.array(domainRowSchema).min(1),
origin_urls_text: z.string().trim().min(1, '请至少填写一个上游地址'),
enabled: z.boolean(),
redirect_http: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
})
.superRefine((value, context) => {
const domains = value.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainError = validateDomains(domains);
if (domainError) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['domain_rows'],
message: domainError,
});
}
const { error } = parseOriginUrls(value.origin_urls_text);
if (error) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['origin_urls_text'],
message: error,
});
}
const selectedCertificateCount = new Set(
value.domain_rows
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
).size;
if (value.redirect_http && selectedCertificateCount === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['redirect_http'],
message: '启用 HTTP 跳转前,请先为域名选择证书',
});
}
});
type CreateWebsiteFormValues = z.infer<typeof createWebsiteSchema>;
const defaultValues: CreateWebsiteFormValues = {
site_name: '',
domain_rows: [{ domain: '', certificateId: '' }],
origin_urls_text: '',
enabled: true,
redirect_http: false,
remark: '',
};
function normalizeSelectedCertificateIDs(rows: DomainListRow[]) {
return Array.from(
new Set(
rows
.filter((item) => item.domain.trim() !== '')
.map((item) => Number(item.certificateId))
.filter((item) => Number.isFinite(item) && item > 0),
),
);
}
function buildDomainCertificateIDs(rows: DomainListRow[]) {
return rows
.filter((item) => item.domain.trim() !== '')
.map((item) => {
const certificateID = Number(item.certificateId);
return Number.isFinite(certificateID) && certificateID > 0
? certificateID
: 0;
});
}
export function ProxyRouteCreateDrawer({
open,
onOpenChange,
onCreated,
domainSuggestionSources = [],
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (route: ProxyRouteItem) => void;
domainSuggestionSources?: string[];
}) {
const form = useForm<CreateWebsiteFormValues>({
resolver: zodResolver(createWebsiteSchema),
defaultValues,
});
const managedDomainsQuery = useQuery({
queryKey: ['managed-domains'],
queryFn: getManagedDomains,
enabled: open,
});
const certificatesQuery = useQuery({
queryKey: ['tls-certificates', 'list'],
queryFn: getTlsCertificates,
enabled: open,
});
const combinedDomainSuggestions = useMemo(
() => [
...domainSuggestionSources,
...(managedDomainsQuery.data?.map((item) => item.domain) ?? []),
],
[domainSuggestionSources, managedDomainsQuery.data],
);
const selectedCertificateIDs = normalizeSelectedCertificateIDs(
form.watch('domain_rows'),
);
const createMutation = useMutation({
mutationFn: async (values: CreateWebsiteFormValues) => {
const domains = values.domain_rows
.map((item) => item.domain.trim().toLowerCase())
.filter(Boolean);
const domainCertIDs = buildDomainCertificateIDs(values.domain_rows);
const selectedCertIDs = normalizeSelectedCertificateIDs(values.domain_rows);
const { urls } = parseOriginUrls(values.origin_urls_text);
const primaryOrigin = parseOriginUrl(urls[0]);
return createProxyRoute({
site_name: values.site_name.trim() || domains[0],
domain: domains[0],
domains,
origin_id: null,
origin_url: buildOriginUrl(
primaryOrigin.scheme,
primaryOrigin.address,
primaryOrigin.port,
primaryOrigin.uri,
),
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_host: '',
upstreams: urls.slice(1),
enabled: values.enabled,
enable_https: selectedCertIDs.length > 0,
cert_id: selectedCertIDs[0] ?? null,
cert_ids: selectedCertIDs,
domain_cert_ids: domainCertIDs,
redirect_http: selectedCertIDs.length > 0 ? values.redirect_http : false,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_rules: [],
custom_headers: [],
pow_enabled: false,
pow_config: '{}',
remark: values.remark.trim(),
});
},
onSuccess: (route) => {
form.reset(defaultValues);
onOpenChange(false);
onCreated(route);
},
});
useEffect(() => {
if (!open) {
form.reset(defaultValues);
}
}, [form, open]);
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="right"
title="新建规则"
footer={
<div className="flex items-center justify-end gap-3">
<PrimaryButton
type="submit"
form="create-website-form"
disabled={createMutation.isPending}
>
{createMutation.isPending ? '创建中...' : '创建'}
</PrimaryButton>
</div>
}
>
<form
id="create-website-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => createMutation.mutate(values))}
>
<ResourceField
label="站点标识"
hint="可选,留空时会自动使用第一个域名。"
error={form.formState.errors.site_name?.message}
>
<ResourceInput
{...form.register('site_name')}
placeholder="marketing-site"
/>
</ResourceField>
<ResourceField
label="域名列表"
hint="每行配置一个域名,可按需为该行选择证书。保存时会自动汇总站点证书集合。"
error={form.formState.errors.domain_rows?.message as string | undefined}
container="div"
>
<Controller
control={form.control}
name="domain_rows"
render={({ field }) => (
<DomainListInput
rows={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
suggestionSources={combinedDomainSuggestions}
certificates={certificatesQuery.data ?? []}
/>
)}
/>
</ResourceField>
<ToggleField
label="HTTP 自动跳转到 HTTPS"
description={
selectedCertificateIDs.length > 0
? '勾选后会额外生成 80 端口重定向规则。'
: '至少为一个域名选择证书后才能启用。'
}
checked={form.watch('redirect_http')}
disabled={selectedCertificateIDs.length === 0}
onChange={(checked) =>
form.setValue('redirect_http', checked, { shouldDirty: true })
}
/>
<ResourceField
label="上游地址"
hint="每行一个完整 URL。第一行作为主回源,多上游模式请保持相同协议且不要包含 path 或 query。"
error={form.formState.errors.origin_urls_text?.message}
>
<ResourceTextarea
aria-label="上游地址"
placeholder={'https://origin-a.internal:443\nhttps://origin-b.internal:443'}
{...form.register('origin_urls_text')}
/>
</ResourceField>
<ToggleField
label="创建后立即启用"
description="关闭后站点会以草稿保存,后续仍可继续编辑。"
checked={form.watch('enabled')}
onChange={(checked) =>
form.setValue('enabled', checked, { shouldDirty: true })
}
/>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceTextarea {...form.register('remark')} />
</ResourceField>
{createMutation.isError ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{getErrorMessage(createMutation.error)}
</p>
) : null}
</form>
</Drawer>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
import type {
ProxyRouteCustomHeader,
ProxyRouteItem,
ProxyRouteMutationPayload,
} from '@/features/proxy-routes/types';
export const websiteConfigSections = [
{
key: 'domains',
label: '域名设置',
description: '维护站点标识、域名列表和证书绑定。',
},
{
key: 'limits',
label: '流量限制',
description: '设置连接数和限速。',
},
{
key: 'proxy',
label: '反向代理',
description: '配置主回源和上游地址。',
},
{
key: 'cache',
label: '缓存',
description: '配置站点缓存策略。',
},
{
key: 'pow',
label: 'PoW 防护',
description: '配置 Proof-of-Work 反爬虫策略。',
},
] as const;
export type WebsiteConfigSectionKey =
(typeof websiteConfigSections)[number]['key'];
const domainPattern =
/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
const originHostPattern =
/^(?:(?:[a-z0-9-]+\.)*[a-z0-9-]+|\[[0-9a-f:.]+\]|[0-9.]+)(?::\d{1,5})?$/i;
const headerKeyPattern = /^[A-Za-z0-9_-]+$/;
const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getWebsiteConfigSection(
value: string | null | undefined,
): WebsiteConfigSectionKey {
return websiteConfigSections.some((section) => section.key === value)
? (value as WebsiteConfigSectionKey)
: 'domains';
}
export function linesFromTextarea(value: string) {
return value
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
}
export function validateDomains(domains: string[]) {
if (domains.length === 0) {
return '请至少填写一个域名';
}
const seen = new Set<string>();
for (const domain of domains) {
const normalized = domain.trim().toLowerCase();
if (!domainPattern.test(normalized)) {
return `域名格式不合法:${domain}`;
}
if (seen.has(normalized)) {
return `域名重复:${domain}`;
}
seen.add(normalized);
}
return null;
}
export function parseOriginUrls(value: string) {
const urls = linesFromTextarea(value);
if (urls.length === 0) {
return { urls: [], error: '请至少填写一个上游地址' };
}
let sharedScheme = '';
for (const originUrl of urls) {
let parsed: URL;
try {
parsed = new URL(originUrl);
} catch {
return { urls: [], error: `上游地址格式不合法:${originUrl}` };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return {
urls: [],
error: `上游地址必须以 http:// 或 https:// 开头:${originUrl}`,
};
}
if (!parsed.hostname) {
return { urls: [], error: `上游地址缺少主机名:${originUrl}` };
}
if (urls.length > 1) {
if ((parsed.pathname && parsed.pathname !== '/') || parsed.search) {
return {
urls: [],
error: '多上游模式暂不支持带路径或查询参数的地址',
};
}
if (!sharedScheme) {
sharedScheme = parsed.protocol;
} else if (sharedScheme !== parsed.protocol) {
return {
urls: [],
error: '同一站点的多个上游必须使用相同协议',
};
}
}
}
return { urls, error: null };
}
export function parseOriginUrl(originUrl: string) {
const parsed = new URL(originUrl);
const port = parsed.port || (parsed.protocol === 'http:' ? '80' : '443');
const path = parsed.pathname === '/' ? '' : parsed.pathname;
return {
scheme: parsed.protocol.replace(':', '') as 'http' | 'https',
address: parsed.hostname,
port,
uri: parsed.search ? `${path}${parsed.search}` || parsed.search : path,
};
}
export function buildOriginUrl(
scheme: 'http' | 'https',
address: string,
port: string,
uri: string,
) {
const normalizedAddress = address.trim();
const normalizedPort = port.trim();
const normalizedURI = uri.trim();
if (!normalizedAddress || !normalizedPort) {
return '';
}
const host =
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
? `[${normalizedAddress}]`
: normalizedAddress;
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
}
export function validateOriginHost(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
if (
normalized.includes('://') ||
/[\/\\\s]/.test(normalized) ||
!originHostPattern.test(normalized)
) {
return '回源 Host 格式不合法';
}
return null;
}
export function parseCustomHeadersText(value: string) {
const lines = linesFromTextarea(value);
const headers: ProxyRouteCustomHeader[] = [];
for (const line of lines) {
const separatorIndex = line.indexOf(':');
if (separatorIndex <= 0) {
return {
headers: [],
error: `自定义请求头格式不合法:${line}`,
};
}
const key = line.slice(0, separatorIndex).trim();
const headerValue = line.slice(separatorIndex + 1).trim();
if (!headerKeyPattern.test(key)) {
return {
headers: [],
error: `自定义请求头名称不合法:${key}`,
};
}
headers.push({ key, value: headerValue });
}
return { headers, error: null };
}
export function customHeadersToText(headers: ProxyRouteCustomHeader[]) {
return headers.map((header) => `${header.key}: ${header.value}`).join('\n');
}
export function validateLimitRate(value: string) {
const normalized = value.trim();
if (!normalized || normalized === '0') {
return null;
}
if (!limitRatePattern.test(normalized)) {
return '限速格式不合法,请使用 512k、1m 或纯数字';
}
return null;
}
export function normalizeLimitRate(value: string) {
const normalized = value.trim().toLowerCase();
return normalized === '0' ? '' : normalized;
}
export function validateCacheRules(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
return null;
}
if (rules.length === 0) {
return '当前缓存策略至少需要一条规则';
}
if (policy === 'suffix') {
for (const rule of rules) {
const normalized = rule.replace(/^\./, '');
if (!normalized || /[\/\\\s]/.test(normalized)) {
return `缓存后缀格式不合法:${rule}`;
}
}
return null;
}
for (const rule of rules) {
if (!rule.startsWith('/') || rule.includes('://') || /[\s]/.test(rule)) {
return `缓存路径规则格式不合法:${rule}`;
}
}
return null;
}
export function buildPayloadFromRoute(
route: ProxyRouteItem,
overrides: Partial<ProxyRouteMutationPayload>,
): ProxyRouteMutationPayload {
const primaryOrigin = parseOriginUrl(route.origin_url);
return {
site_name: route.site_name,
domain: route.primary_domain,
domains: route.domains,
origin_id: null,
origin_url: route.origin_url,
origin_scheme: primaryOrigin.scheme,
origin_address: primaryOrigin.address,
origin_port: primaryOrigin.port,
origin_uri: primaryOrigin.uri,
origin_host: route.origin_host || '',
upstreams: route.upstream_list.slice(1),
enabled: route.enabled,
enable_https: route.enable_https,
cert_id: route.cert_id,
cert_ids: route.cert_ids,
domain_cert_ids: route.domain_cert_ids,
redirect_http: route.redirect_http,
limit_conn_per_server: route.limit_conn_per_server,
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
cache_policy: route.cache_policy || 'url',
cache_rules: route.cache_rule_list,
custom_headers: route.custom_header_list,
remark: route.remark || '',
pow_enabled: route.pow_enabled,
pow_config: JSON.stringify(route.pow_config),
...overrides,
};
}
export function getUpstreamSummary(route: ProxyRouteItem) {
if (route.upstream_list.length <= 1) {
return route.origin_url;
}
return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`;
}
export function getWebsiteStatusBadges(route: ProxyRouteItem) {
return [
route.enabled
? { label: '已启用', variant: 'success' as const }
: { label: '已停用', variant: 'warning' as const },
route.enable_https
? { label: 'HTTPS', variant: 'info' as const }
: { label: 'HTTP', variant: 'warning' as const },
];
}
@@ -3,28 +3,61 @@ export interface ProxyRouteCustomHeader {
value: string;
}
export interface ProxyRoutePoWListConfig {
ips: string[];
ip_cidrs: string[];
paths: string[];
path_regexes: string[];
user_agents: string[];
}
export interface ProxyRoutePoWConfig {
difficulty: number;
algorithm: 'fast' | 'slow';
session_ttl: number;
challenge_ttl: number;
whitelist: ProxyRoutePoWListConfig;
blacklist: ProxyRoutePoWListConfig;
}
export interface ProxyRouteItem {
id: number;
site_name: string;
domain: string;
domains: string[];
primary_domain: string;
domain_count: number;
origin_id: number | null;
origin_url: string;
origin_host: string;
upstreams: string;
upstream_list: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids: number[];
domain_cert_ids: number[];
redirect_http: boolean;
limit_conn_per_server: number;
limit_conn_per_ip: number;
limit_rate: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string;
cache_rule_list: string[];
custom_headers: string;
custom_header_list: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
created_at: string;
updated_at: string;
}
export interface ProxyRouteMutationPayload {
site_name?: string;
domain: string;
domains?: string[];
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
@@ -36,11 +69,18 @@ export interface ProxyRouteMutationPayload {
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids?: number[];
domain_cert_ids?: number[];
redirect_http: boolean;
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
limit_rate?: string;
cache_enabled: boolean;
cache_policy: string;
cache_rules: string[];
custom_headers: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: string;
remark: string;
}
@@ -5,6 +5,7 @@ import type {
DatabaseCleanupPayload,
DatabaseCleanupResult,
GeoIPLookupResult,
OptionBatchPayload,
OptionItem,
SettingsProfile,
UpdateSelfPayload,
@@ -21,6 +22,13 @@ export function updateOption(key: string, value: string) {
});
}
export function updateOptions(options: OptionBatchPayload['options']) {
return apiRequest<void>('/option/update-batch', {
method: 'POST',
body: JSON.stringify({ options }),
});
}
export function lookupGeoIP(provider: string, ip: string) {
return apiRequest<GeoIPLookupResult>('/option/geoip/lookup', {
method: 'POST',
@@ -25,7 +25,7 @@ import {
getSettingsProfile,
lookupGeoIP,
rotateBootstrapToken,
updateOption,
updateOptions,
updateSelf,
} from '@/features/settings/api/settings';
import type {
@@ -61,7 +61,7 @@ const defaultSystemFields = {
GitHubOAuthEnabled: false,
WeChatAuthEnabled: false,
TurnstileCheckEnabled: false,
RegisterEnabled: true,
RegisterEnabled: false,
SMTPServer: '',
SMTPPort: '587',
SMTPAccount: '',
@@ -348,7 +348,7 @@ export function SettingsPage() {
GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false),
WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false),
TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false),
RegisterEnabled: toBoolean(optionMap.RegisterEnabled, true),
RegisterEnabled: toBoolean(optionMap.RegisterEnabled, false),
SMTPServer: optionMap.SMTPServer ?? '',
SMTPPort: optionMap.SMTPPort ?? '587',
SMTPAccount: optionMap.SMTPAccount ?? '',
@@ -558,9 +558,7 @@ export function SettingsPage() {
entries: Array<[string, string]>,
successMessage: string,
) => {
for (const [key, value] of entries) {
await updateOption(key, value);
}
await updateOptions(entries.map(([key, value]) => ({ key, value })));
await queryClient.invalidateQueries({ queryKey: settingsQueryKey });
await queryClient.invalidateQueries({ queryKey: ['public-status'] });
@@ -5,6 +5,10 @@ export interface OptionItem {
value: string;
}
export interface OptionBatchPayload {
options: OptionItem[];
}
export interface BootstrapTokenPayload {
discovery_token: string;
}
@@ -14,6 +14,7 @@ interface ResourceFieldProps {
error?: string;
className?: string;
tooltip?: string;
container?: 'label' | 'div';
children: ReactNode;
}
@@ -36,10 +37,13 @@ export function ResourceField({
error,
className,
tooltip,
container = 'label',
children,
}: ResourceFieldProps) {
const Container = container;
return (
<label className={cn('block space-y-2', className)}>
<Container className={cn('block space-y-2', className)}>
<span className="flex items-center gap-2 text-sm font-medium text-[var(--foreground-primary)]">
<span>{label}</span>
{tooltip ? <FieldTooltip content={tooltip} /> : null}
@@ -54,7 +58,7 @@ export function ResourceField({
{hint}
</span>
) : null}
</label>
</Container>
);
}
@@ -0,0 +1,88 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, waitFor } from '@testing-library/react';
import { StrictMode, type ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GitHubOAuthCallback } from '@/features/auth/components/github-oauth-callback';
const replaceMock = vi.fn();
const setUserMock = vi.fn();
const exchangeGitHubCodeMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
replace: replaceMock,
}),
useSearchParams: () => new URLSearchParams('code=github-code-123'),
}));
vi.mock('@/components/providers/auth-provider', () => ({
useAuth: () => ({
setUser: setUserMock,
}),
}));
vi.mock('@/features/auth/api/auth', () => ({
exchangeGitHubCode: (code: string) => exchangeGitHubCodeMock(code),
}));
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
}
function renderWithProviders(ui: ReactNode) {
const queryClient = createQueryClient();
return render(
<StrictMode>
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
</StrictMode>,
);
}
describe('GitHubOAuthCallback', () => {
beforeEach(() => {
replaceMock.mockReset();
setUserMock.mockReset();
exchangeGitHubCodeMock.mockReset();
exchangeGitHubCodeMock.mockResolvedValue({
id: 1,
username: 'github-user',
role: 1,
status: 1,
});
});
it('exchanges the same GitHub code only once', async () => {
const view = renderWithProviders(<GitHubOAuthCallback />);
await waitFor(() => {
expect(exchangeGitHubCodeMock).toHaveBeenCalledTimes(1);
});
view.rerender(
<StrictMode>
<QueryClientProvider client={createQueryClient()}>
<GitHubOAuthCallback />
</QueryClientProvider>
</StrictMode>,
);
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith('/');
});
expect(exchangeGitHubCodeMock).toHaveBeenCalledTimes(1);
expect(exchangeGitHubCodeMock).toHaveBeenCalledWith('github-code-123');
expect(setUserMock).toHaveBeenCalledTimes(1);
});
});
@@ -1,441 +1,437 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, within } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { ProxyRouteConfigPage } from '@/features/proxy-routes/components/proxy-route-config-page';
import { ProxyRoutesPage } from '@/features/proxy-routes/components/proxy-routes-page';
describe('ProxyRoutesPage', () => {
const pushMock = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: pushMock,
}),
}));
function stubMatchMedia() {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
}
function buildRoute(overrides: Record<string, unknown> = {}) {
return {
id: 9,
site_name: 'marketing-site',
domain: 'app.example.com',
domains: ['app.example.com', 'www.example.com'],
primary_domain: 'app.example.com',
domain_count: 2,
origin_id: null,
origin_url: 'https://origin-a.internal:443',
origin_host: '',
upstreams: JSON.stringify([
'https://origin-a.internal:443',
'https://origin-b.internal:443',
]),
upstream_list: [
'https://origin-a.internal:443',
'https://origin-b.internal:443',
],
enabled: true,
enable_https: true,
cert_id: 1,
cert_ids: [1],
domain_cert_ids: [1, 0],
redirect_http: true,
limit_conn_per_server: 120,
limit_conn_per_ip: 12,
limit_rate: '512k',
cache_enabled: true,
cache_policy: 'path_prefix',
cache_rules: JSON.stringify(['/assets']),
cache_rule_list: ['/assets'],
custom_headers: JSON.stringify([{ key: 'X-Site', value: 'marketing' }]),
custom_header_list: [{ key: 'X-Site', value: 'marketing' }],
remark: 'Marketing website',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-21T08:00:00Z',
...overrides,
};
}
function buildDiff(overrides: Record<string, unknown> = {}) {
return {
active_version: '20260330-001',
added_sites: [],
removed_sites: [],
modified_sites: [],
added_domains: [],
removed_domains: [],
modified_domains: [],
main_config_changed: false,
changed_option_keys: [],
changed_option_details: [],
current_website_count: 1,
active_website_count: 1,
...overrides,
};
}
function renderWithProviders(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>{ui}</ThemeProvider>
</QueryClientProvider>,
);
}
describe('Proxy route website pages', () => {
beforeEach(() => {
pushMock.mockReset();
stubMatchMedia();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function stubMatchMedia() {
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
);
}
function renderProxyRoutesPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ProxyRoutesPage />
</ThemeProvider>
</QueryClientProvider>,
);
}
function buildBaseFetchStub({
proxyRoutes = [],
managedDomains,
origins = [],
certificates = [{ id: 1, name: 'example-cert', not_after: null }],
matchResult,
}: {
proxyRoutes?: Array<Record<string, unknown>>;
managedDomains: Array<Record<string, unknown>>;
origins?: Array<Record<string, unknown>>;
certificates?: Array<Record<string, unknown>>;
matchResult?: Record<string, unknown>;
}) {
return vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/proxy-routes/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: proxyRoutes,
}),
),
);
}
if (url.includes('/managed-domains/match?')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data:
matchResult ??
{
domain: 'a.example.com',
matched: true,
candidate: {
managed_domain_id: 2,
domain: 'a.example.com',
match_type: 'exact',
certificate_id: 1,
certificate_name: 'example-cert',
},
candidates: [],
},
}),
),
);
}
if (url.includes('/managed-domains/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: managedDomains,
}),
),
);
}
if (url.includes('/origins/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: origins,
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: certificates,
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
});
}
it('shows wildcard subdomain input after selecting a wildcard website', async () => {
stubMatchMedia();
it('renders website list summary with config entry', async () => {
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
{
id: 2,
domain: 'a.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/proxy-routes/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [buildRoute()],
}),
),
);
}
if (url.includes('/config-versions/diff')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: buildDiff({
modified_sites: ['marketing-site'],
}),
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
renderProxyRoutesPage();
renderWithProviders(<ProxyRoutesPage />);
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
await user.type(domainInput, 'example');
await user.keyboard('{Enter}');
expect(await screen.findByPlaceholderText('e.g. ai')).toBeInTheDocument();
expect(domainInput).toHaveValue('*.example.com');
await user.type(screen.getByPlaceholderText('e.g. ai'), 'ai');
expect(screen.getByPlaceholderText('e.g. ai')).toHaveValue('ai');
expect(await screen.findByText('marketing-site')).toBeInTheDocument();
expect(screen.getAllByText(/app\.example\.com/).length).toBeGreaterThan(0);
expect(screen.getByRole('link')).toHaveAttribute(
'href',
'/proxy-route/detail?id=9&section=domains',
);
});
it('uses exact website directly without showing subdomain input', async () => {
stubMatchMedia();
it('creates a website and navigates to config page', async () => {
const routes: Array<Record<string, unknown>> = [];
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 2,
domain: 'a.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method?.toUpperCase() ?? 'GET';
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
await user.type(domainInput, 'a.example');
await user.keyboard('{Enter}');
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
expect(domainInput).toHaveValue('a.example.com');
expect(await screen.findByText('Select Certificate')).toBeInTheDocument();
});
it('shows origin autocomplete suggestions and empty state', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
origins: [
{
id: 1,
name: 'Main Backend',
address: '192.168.1.45',
remark: '',
route_count: 1,
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名');
await user.type(domainInput, 'example');
await user.keyboard('{Enter}');
const addressInput = screen.getByPlaceholderText('192.168.1.45');
await user.click(addressInput);
await user.type(addressInput, '192.');
expect(await screen.findByText('192.168.1.45')).toBeInTheDocument();
expect(await screen.findByText('(Main Backend)')).toBeInTheDocument();
expect(await screen.findByText('Local')).toBeInTheDocument();
await user.clear(addressInput);
await user.type(addressInput, '10.10.');
expect(
await screen.findByText('未发现匹配资产,请手动输入'),
).toBeInTheDocument();
});
it('opens create drawer with advanced section expanded and http selected by default', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
expect(within(dialog).getByRole('heading', { name: 'Advanced' })).toBeInTheDocument();
expect(within(dialog).getByLabelText('协议 1')).toHaveTextContent('HTTP');
});
it('closes managed domain suggestions when clicking outside', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const domainInput = await screen.findByPlaceholderText('输入并搜索目标域名');
await user.click(domainInput);
await user.type(domainInput, 'exam');
expect(await screen.findByText('*.example.com')).toBeInTheDocument();
await user.click(
within(await screen.findByRole('dialog', { name: '新增规则' })).getByRole(
'heading',
{ name: 'Protocol' },
),
);
expect(screen.queryByText('*.example.com')).not.toBeInTheDocument();
});
it('supports arrow key navigation for managed domain suggestions', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
managedDomains: [
{
id: 1,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
{
id: 2,
domain: 'app.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
}),
);
renderProxyRoutesPage();
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '新增规则' }));
const dialog = await screen.findByRole('dialog', { name: '新增规则' });
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
await user.type(domainInput, 'example');
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(domainInput).toHaveValue('app.example.com');
expect(screen.queryByPlaceholderText('e.g. ai')).not.toBeInTheDocument();
});
it('prefills managed domain and origin rows correctly when editing an existing rule', async () => {
stubMatchMedia();
vi.stubGlobal(
'fetch',
buildBaseFetchStub({
proxyRoutes: [
{
id: 3,
domain: 'ai.example.com',
origin_id: 1,
origin_url: 'https://c2.internal:443',
origin_host: '',
upstreams: JSON.stringify(['https://c2.internal:443']),
enabled: true,
enable_https: true,
cert_id: 1,
redirect_http: false,
if (url.includes('/proxy-routes/') && method === 'POST') {
const payload = JSON.parse(String(init?.body));
const created = buildRoute({
id: 21,
site_name: payload.site_name,
domain: payload.domain,
domains: payload.domains,
primary_domain: payload.domain,
domain_count: payload.domains.length,
origin_url: payload.origin_url,
upstreams: JSON.stringify([payload.origin_url, ...payload.upstreams]),
upstream_list: [payload.origin_url, ...payload.upstreams],
enabled: payload.enabled,
enable_https: payload.enable_https,
cert_id: payload.cert_id,
cert_ids: payload.cert_ids ?? [],
domain_cert_ids: payload.domain_cert_ids ?? [],
redirect_http: payload.redirect_http,
limit_conn_per_server: 0,
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_rules: '[]',
cache_rule_list: [],
custom_headers: '[]',
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
managedDomains: [
{
id: 3,
domain: '*.example.com',
cert_id: 1,
enabled: true,
remark: '',
created_at: '2026-03-20T08:00:00Z',
updated_at: '2026-03-20T08:00:00Z',
},
],
custom_header_list: [],
remark: payload.remark,
});
routes.splice(0, routes.length, created);
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: created,
}),
),
);
}
if (url.includes('/proxy-routes/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: routes,
}),
),
);
}
if (url.includes('/config-versions/diff')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: buildDiff(),
}),
),
);
}
if (url.includes('/managed-domains/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }],
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, name: 'example-cert', not_after: null }],
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
renderProxyRoutesPage();
renderWithProviders(<ProxyRoutesPage />);
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: '编辑' }));
const dialog = await screen.findByRole('dialog', { name: '编辑规则' });
const pageButtons = await screen.findAllByRole('button');
await user.click(pageButtons[1]);
const domainInput = within(dialog).getByPlaceholderText('输入并搜索目标域名');
expect(domainInput).toHaveValue('*.example.com');
expect(within(dialog).getByPlaceholderText('e.g. ai')).toHaveValue('ai');
expect(within(dialog).getAllByPlaceholderText('192.168.1.45')).toHaveLength(1);
expect(within(dialog).getByPlaceholderText('192.168.1.45')).toHaveValue(
'c2.internal',
const dialog = await screen.findByRole('dialog');
expect(dialog).toBeInTheDocument();
await user.type(
within(dialog).getByPlaceholderText('marketing-site'),
'launch-site',
);
const primaryDomainInput = within(dialog).getByLabelText('域名 1');
await user.type(primaryDomainInput, 'app.exam');
await user.click(
await within(dialog).findByRole('button', { name: 'app.example.com' }),
);
await user.click(within(dialog).getByLabelText('新增域名输入框'));
await user.type(within(dialog).getByLabelText('域名 2'), 'www.example.com');
await user.type(
within(dialog).getByLabelText('上游地址'),
'https://origin-a.internal:443{enter}https://origin-b.internal:443',
);
const submitButton = document.querySelector(
'button[form="create-website-form"]',
) as HTMLButtonElement | null;
expect(submitButton).toBeInstanceOf(HTMLButtonElement);
if (!submitButton) {
throw new Error('missing create submit button');
}
await user.click(submitButton);
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith(
'/proxy-route/detail?id=21&section=domains',
);
});
});
it('saves domain settings from config page by section', async () => {
const updateRequests: Array<Record<string, unknown>> = [];
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = init?.method?.toUpperCase() ?? 'GET';
if (url.includes('/proxy-routes/9/update') && method === 'POST') {
const payload = JSON.parse(String(init?.body)) as Record<
string,
unknown
>;
updateRequests.push(payload);
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: buildRoute({
site_name: payload.site_name,
domain: (payload.domains as string[])[0],
domains: payload.domains,
primary_domain: (payload.domains as string[])[0],
domain_count: (payload.domains as string[]).length,
enabled: payload.enabled,
enable_https: payload.enable_https,
cert_id: payload.cert_id,
cert_ids: payload.cert_ids,
domain_cert_ids: payload.domain_cert_ids,
redirect_http: payload.redirect_http,
}),
}),
),
);
}
if (url.includes('/proxy-routes/9')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: buildRoute(),
}),
),
);
}
if (url.includes('/tls-certificates/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, name: 'example-cert', not_after: null }],
}),
),
);
}
if (url.includes('/managed-domains/')) {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
message: '',
data: [{ id: 1, domain: '*.example.com', cert_id: 1, enabled: true }],
}),
),
);
}
return Promise.reject(new Error(`Unhandled fetch: ${url}`));
}),
);
renderWithProviders(
<ProxyRouteConfigPage routeId="9" initialSection="domains" />,
);
const user = userEvent.setup();
expect(await screen.findByText('marketing-site')).toBeInTheDocument();
const siteNameInput = screen.getByPlaceholderText('marketing-site');
await user.clear(siteNameInput);
await user.type(siteNameInput, 'brand-site');
const primaryDomainInput = screen.getByLabelText('域名 1');
await user.clear(primaryDomainInput);
await user.type(primaryDomainInput, 'brand.example.com');
const secondaryDomainInput = screen.getByLabelText('域名 2');
await user.clear(secondaryDomainInput);
await user.type(secondaryDomainInput, 'www.brand.example.com');
await user.selectOptions(screen.getByLabelText('证书 1'), '1');
await user.selectOptions(screen.getByLabelText('证书 2'), '');
const saveButton = document.querySelector(
'button[form="proxy-route-domains-form"]',
) as HTMLButtonElement | null;
expect(saveButton).toBeInstanceOf(HTMLButtonElement);
if (!saveButton) {
throw new Error('missing domain save button');
}
await user.click(saveButton);
await waitFor(() => {
expect(updateRequests).toHaveLength(1);
});
expect(updateRequests[0]).toMatchObject({
site_name: 'brand-site',
domain: 'brand.example.com',
domains: ['brand.example.com', 'www.brand.example.com'],
enabled: true,
enable_https: true,
cert_id: 1,
cert_ids: [1],
domain_cert_ids: [1, 0],
redirect_http: true,
});
});
});
+7 -5
View File
@@ -8,8 +8,10 @@ export interface PublicStatus {
home_page_link: string;
footer_html: string;
wechat_qrcode: string;
wechat_login: boolean;
server_address: string;
turnstile_check: boolean;
turnstile_site_key: string;
}
wechat_login: boolean;
server_address: string;
turnstile_check: boolean;
turnstile_site_key: string;
register_enabled: boolean;
password_register_enabled: boolean;
}
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="/opt/openflare-agent"
SERVICE_NAME="openflare-agent"
usage() {
cat <<EOF
OpenFlare Agent Uninstaller
Usage:
uninstall-agent.sh [OPTIONS]
Options:
--install-dir DIR Installation directory (default: /opt/openflare-agent)
--service-name NAME systemd service name (default: openflare-agent)
-h, --help Show this help message
Behavior:
1. Stop the agent service/process and remove the entire installation directory
2. Remove the systemd service definition when present
3. Check the saved agent config to identify the OpenResty mode
4. If Docker mode was used, remove the OpenResty container and try to remove its image
5. If local openresty_path mode was used, do not modify the local OpenResty install
Examples:
uninstall-agent.sh
uninstall-agent.sh --install-dir /srv/openflare-agent
EOF
exit 0
}
while [[ $# -gt 0 ]]; do
case "$1" in
--install-dir) INSTALL_DIR="$2"; shift 2 ;;
--service-name) SERVICE_NAME="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
if [[ -z "$INSTALL_DIR" || "$INSTALL_DIR" == "/" || "$INSTALL_DIR" == "." ]]; then
echo "Refusing to remove unsafe install directory: '${INSTALL_DIR}'"
exit 1
fi
json_get_string() {
local file="$1"
local key="$2"
local match
match=$(grep -o "\"${key}\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" 2>/dev/null | head -n 1 || true)
if [[ -z "$match" ]]; then
return 0
fi
printf '%s\n' "$match" | sed -E 's/.*:[[:space:]]*"([^"]*)"/\1/'
}
AGENT_BINARY="${INSTALL_DIR}/openflare-agent"
CONFIG_FILE="${INSTALL_DIR}/agent.json"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
OPENRESTY_PATH=""
OPENRESTY_CONTAINER_NAME="openflare-openresty"
OPENRESTY_DOCKER_IMAGE="openresty/openresty:alpine"
DOCKER_BINARY="docker"
OPENRESTY_MODE="unknown"
if [[ -f "$CONFIG_FILE" ]]; then
OPENRESTY_PATH="$(json_get_string "$CONFIG_FILE" "openresty_path")"
OPENRESTY_CONTAINER_NAME="$(json_get_string "$CONFIG_FILE" "openresty_container_name")"
OPENRESTY_DOCKER_IMAGE="$(json_get_string "$CONFIG_FILE" "openresty_docker_image")"
DOCKER_BINARY="$(json_get_string "$CONFIG_FILE" "docker_binary")"
if [[ -z "$OPENRESTY_CONTAINER_NAME" ]]; then
OPENRESTY_CONTAINER_NAME="openflare-openresty"
fi
if [[ -z "$OPENRESTY_DOCKER_IMAGE" ]]; then
OPENRESTY_DOCKER_IMAGE="openresty/openresty:alpine"
fi
if [[ -z "$DOCKER_BINARY" ]]; then
DOCKER_BINARY="docker"
fi
if [[ -n "$OPENRESTY_PATH" ]]; then
OPENRESTY_MODE="local"
else
OPENRESTY_MODE="docker"
fi
fi
SYSTEMCTL_AVAILABLE="false"
if command -v systemctl >/dev/null 2>&1; then
SYSTEMCTL_AVAILABLE="true"
fi
echo "Uninstalling OpenFlare Agent from ${INSTALL_DIR}..."
if [[ "$SYSTEMCTL_AVAILABLE" == "true" ]]; then
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo "Stopping service: ${SERVICE_NAME}"
systemctl stop "$SERVICE_NAME"
fi
if systemctl is-enabled --quiet "$SERVICE_NAME" >/dev/null 2>&1; then
echo "Disabling service: ${SERVICE_NAME}"
systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
fi
fi
if command -v pgrep >/dev/null 2>&1; then
mapfile -t agent_pids < <(pgrep -f "$AGENT_BINARY" || true)
if (( ${#agent_pids[@]} > 0 )); then
echo "Stopping agent process: ${agent_pids[*]}"
kill "${agent_pids[@]}" || true
sleep 1
mapfile -t remaining_agent_pids < <(pgrep -f "$AGENT_BINARY" || true)
if (( ${#remaining_agent_pids[@]} > 0 )); then
echo "Force stopping remaining agent process: ${remaining_agent_pids[*]}"
kill -9 "${remaining_agent_pids[@]}" || true
fi
fi
fi
if [[ -f "$SERVICE_FILE" ]]; then
echo "Removing service file: ${SERVICE_FILE}"
rm -f "$SERVICE_FILE"
fi
if [[ "$SYSTEMCTL_AVAILABLE" == "true" ]]; then
systemctl daemon-reload || true
systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true
fi
if [[ -d "$INSTALL_DIR" ]]; then
echo "Removing installation directory: ${INSTALL_DIR}"
rm -rf "$INSTALL_DIR"
else
echo "Installation directory not found, skipping: ${INSTALL_DIR}"
fi
echo "Agent uninstall complete."
echo ""
echo "Checking OpenResty installation mode..."
if [[ "$OPENRESTY_MODE" == "docker" ]]; then
echo "Detected Docker OpenResty mode."
if ! command -v "$DOCKER_BINARY" >/dev/null 2>&1; then
echo "Docker binary '${DOCKER_BINARY}' was not found."
echo "Please remove container '${OPENRESTY_CONTAINER_NAME}' and image '${OPENRESTY_DOCKER_IMAGE}' manually."
exit 0
fi
if "$DOCKER_BINARY" inspect "$OPENRESTY_CONTAINER_NAME" >/dev/null 2>&1; then
if [[ -z "$OPENRESTY_DOCKER_IMAGE" ]]; then
OPENRESTY_DOCKER_IMAGE="$("$DOCKER_BINARY" inspect -f '{{.Config.Image}}' "$OPENRESTY_CONTAINER_NAME" 2>/dev/null || true)"
fi
echo "Removing Docker container: ${OPENRESTY_CONTAINER_NAME}"
"$DOCKER_BINARY" rm -f "$OPENRESTY_CONTAINER_NAME"
else
echo "Docker container not found, skipping: ${OPENRESTY_CONTAINER_NAME}"
fi
if [[ -n "$OPENRESTY_DOCKER_IMAGE" ]] && "$DOCKER_BINARY" image inspect "$OPENRESTY_DOCKER_IMAGE" >/dev/null 2>&1; then
other_container_ids="$("$DOCKER_BINARY" ps -a --filter "ancestor=${OPENRESTY_DOCKER_IMAGE}" --format '{{.ID}}' 2>/dev/null || true)"
if [[ -z "$other_container_ids" ]]; then
echo "Removing Docker image: ${OPENRESTY_DOCKER_IMAGE}"
if ! "$DOCKER_BINARY" image rm "$OPENRESTY_DOCKER_IMAGE"; then
echo "Image removal skipped because Docker reported it is still in use."
fi
else
echo "Docker image is still used by other containers, skipping image removal: ${OPENRESTY_DOCKER_IMAGE}"
fi
fi
echo "Docker OpenResty cleanup complete."
elif [[ "$OPENRESTY_MODE" == "local" ]]; then
echo "Detected local OpenResty mode via openresty_path:"
echo " ${OPENRESTY_PATH}"
echo "Agent has been removed, but the local OpenResty installation was not modified."
echo "Please uninstall the local OpenResty manually if you no longer need it."
else
echo "OpenResty mode could not be determined because ${CONFIG_FILE} was not found before uninstall."
echo "If you were using Docker OpenResty, please remove its container and image manually if needed."
fi
echo ""
echo "OpenFlare Agent uninstall finished."